@vellumai/vellum-gateway 0.8.2 → 0.8.4
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 +1 -1
- package/package.json +1 -1
- package/src/__tests__/config-file-watcher.test.ts +57 -0
- package/src/__tests__/ipc-slack-thread-routes.test.ts +157 -0
- package/src/__tests__/route-schema-guard.test.ts +4 -0
- package/src/__tests__/slack-display-name.test.ts +218 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +98 -4
- package/src/__tests__/twilio-webhooks.test.ts +47 -0
- package/src/auth/ipc-route-policy.ts +6 -0
- package/src/channels/inbound-event.ts +8 -2
- package/src/channels/types.ts +2 -0
- package/src/config-file-watcher.ts +44 -1
- package/src/db/slack-store.ts +10 -0
- package/src/feature-flag-registry.json +111 -23
- package/src/handlers/handle-inbound.ts +6 -4
- package/src/http/routes/a2a-routes.test.ts +129 -0
- package/src/http/routes/a2a-routes.ts +121 -0
- package/src/http/routes/twilio-voice-verify-callback.ts +41 -12
- package/src/http/routes/twilio-voice-webhook.test.ts +55 -0
- package/src/http/routes/twilio-voice-webhook.ts +10 -2
- package/src/index.ts +16 -0
- package/src/ipc/slack-thread-handlers.ts +39 -0
- package/src/risk/bash-risk-classifier.test.ts +24 -0
- package/src/risk/command-registry/commands/assistant.ts +33 -0
- package/src/risk/command-registry.test.ts +5 -0
- package/src/runtime/client.ts +66 -14
- package/src/slack/normalize.ts +78 -26
- package/src/slack/socket-mode.ts +2 -2
- package/src/twilio/validate-webhook.ts +7 -1
- package/src/types.ts +1 -0
- package/src/velay/client.test.ts +100 -0
- package/src/velay/client.ts +73 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// --- Workspace dir mock -----------------------------------------------------
|
|
7
|
+
|
|
8
|
+
let testWorkspaceDir: string;
|
|
9
|
+
|
|
10
|
+
mock.module("../../credential-reader.js", () => ({
|
|
11
|
+
getWorkspaceDir: () => testWorkspaceDir,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Import after mocks are registered
|
|
15
|
+
const { createAgentCardHandler } = await import("./a2a-routes.js");
|
|
16
|
+
|
|
17
|
+
// --- Helpers ---------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function makeConfigFileCache(overrides?: {
|
|
20
|
+
a2aEnabled?: boolean;
|
|
21
|
+
publicBaseUrl?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const data: Record<string, Record<string, unknown>> = {
|
|
24
|
+
a2a: { enabled: overrides?.a2aEnabled ?? false },
|
|
25
|
+
ingress: {
|
|
26
|
+
publicBaseUrl: overrides?.publicBaseUrl ?? "https://example.com",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
getBoolean: (section: string, field: string) => {
|
|
32
|
+
const val = data[section]?.[field];
|
|
33
|
+
return typeof val === "boolean" ? val : undefined;
|
|
34
|
+
},
|
|
35
|
+
getString: (section: string, field: string) => {
|
|
36
|
+
const val = data[section]?.[field];
|
|
37
|
+
return typeof val === "string" ? val : undefined;
|
|
38
|
+
},
|
|
39
|
+
} as import("../../config-file-cache.js").ConfigFileCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Setup / teardown -------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
testWorkspaceDir = mkdtempSync(join(tmpdir(), "a2a-test-"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
rmSync(testWorkspaceDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// --- Tests -----------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe("Agent Card", () => {
|
|
55
|
+
it("returns 404 when A2A is not enabled", async () => {
|
|
56
|
+
const configFile = makeConfigFileCache({ a2aEnabled: false });
|
|
57
|
+
const handler = createAgentCardHandler(configFile);
|
|
58
|
+
|
|
59
|
+
const res = await handler(
|
|
60
|
+
new Request("http://localhost:7830/.well-known/agent-card.json"),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(res.status).toBe(404);
|
|
64
|
+
const body = (await res.json()) as { error: string };
|
|
65
|
+
expect(body.error).toContain("not enabled");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("serves agent card with fallback name when no IDENTITY.md", async () => {
|
|
69
|
+
const configFile = makeConfigFileCache({
|
|
70
|
+
a2aEnabled: true,
|
|
71
|
+
publicBaseUrl: "https://my-assistant.example.com",
|
|
72
|
+
});
|
|
73
|
+
const handler = createAgentCardHandler(configFile);
|
|
74
|
+
|
|
75
|
+
const res = await handler(
|
|
76
|
+
new Request("http://localhost:7830/.well-known/agent-card.json"),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
const card = (await res.json()) as {
|
|
81
|
+
name: string;
|
|
82
|
+
supported_interfaces: Array<{ url: string }>;
|
|
83
|
+
capabilities: { push_notifications: boolean };
|
|
84
|
+
};
|
|
85
|
+
expect(card.name).toBe("Vellum Assistant");
|
|
86
|
+
expect(card.supported_interfaces[0].url).toBe(
|
|
87
|
+
"https://my-assistant.example.com/a2a/message:send",
|
|
88
|
+
);
|
|
89
|
+
expect(card.capabilities.push_notifications).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reads assistant name from IDENTITY.md", async () => {
|
|
93
|
+
const promptsDir = join(testWorkspaceDir, "prompts");
|
|
94
|
+
mkdirSync(promptsDir, { recursive: true });
|
|
95
|
+
writeFileSync(
|
|
96
|
+
join(promptsDir, "IDENTITY.md"),
|
|
97
|
+
"**Name:** Alice\n\nA helpful research assistant.",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const configFile = makeConfigFileCache({
|
|
101
|
+
a2aEnabled: true,
|
|
102
|
+
publicBaseUrl: "https://alice.example.com",
|
|
103
|
+
});
|
|
104
|
+
const handler = createAgentCardHandler(configFile);
|
|
105
|
+
|
|
106
|
+
const res = await handler(
|
|
107
|
+
new Request("http://localhost:7830/.well-known/agent-card.json"),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
const card = (await res.json()) as { name: string; description: string };
|
|
112
|
+
expect(card.name).toBe("Alice");
|
|
113
|
+
expect(card.description).toBe("Alice — a Vellum AI assistant");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns 503 when no public base URL is configured", async () => {
|
|
117
|
+
const configFile = makeConfigFileCache({
|
|
118
|
+
a2aEnabled: true,
|
|
119
|
+
publicBaseUrl: "",
|
|
120
|
+
});
|
|
121
|
+
const handler = createAgentCardHandler(configFile);
|
|
122
|
+
|
|
123
|
+
const res = await handler(
|
|
124
|
+
new Request("http://localhost:7830/.well-known/agent-card.json"),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(res.status).toBe(503);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A agent card discovery endpoint:
|
|
3
|
+
* - GET /.well-known/agent-card.json — agent card for peer discovery
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import type { ConfigFileCache } from "../../config-file-cache.js";
|
|
10
|
+
import { getWorkspaceDir } from "../../credential-reader.js";
|
|
11
|
+
import { getLogger } from "../../logger.js";
|
|
12
|
+
|
|
13
|
+
const log = getLogger("a2a-routes");
|
|
14
|
+
|
|
15
|
+
// ── A2A protocol constants (duplicated to avoid cross-package import) ──
|
|
16
|
+
|
|
17
|
+
const A2A_AGENT_CARD_PATH = "/.well-known/agent-card.json";
|
|
18
|
+
|
|
19
|
+
// ── Agent card builder ──────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
interface AgentCard {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
version: string;
|
|
25
|
+
supported_interfaces: Array<{
|
|
26
|
+
url: string;
|
|
27
|
+
protocol_binding: string;
|
|
28
|
+
protocol_version: string;
|
|
29
|
+
}>;
|
|
30
|
+
capabilities: {
|
|
31
|
+
streaming: boolean;
|
|
32
|
+
push_notifications: boolean;
|
|
33
|
+
extended_agent_card: boolean;
|
|
34
|
+
};
|
|
35
|
+
default_input_modes: string[];
|
|
36
|
+
default_output_modes: string[];
|
|
37
|
+
skills: Array<{
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
tags: string[];
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildAgentCard(baseUrl: string, assistantName: string): AgentCard {
|
|
46
|
+
return {
|
|
47
|
+
name: assistantName,
|
|
48
|
+
description: `${assistantName} — a Vellum AI assistant`,
|
|
49
|
+
version: "1.0.0",
|
|
50
|
+
supported_interfaces: [
|
|
51
|
+
{
|
|
52
|
+
url: `${baseUrl}/a2a/message:send`,
|
|
53
|
+
protocol_binding: "JSONRPC",
|
|
54
|
+
protocol_version: "1.0",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
capabilities: {
|
|
58
|
+
streaming: false,
|
|
59
|
+
push_notifications: true,
|
|
60
|
+
extended_agent_card: false,
|
|
61
|
+
},
|
|
62
|
+
default_input_modes: ["text/plain"],
|
|
63
|
+
default_output_modes: ["text/plain"],
|
|
64
|
+
skills: [
|
|
65
|
+
{
|
|
66
|
+
id: "conversation",
|
|
67
|
+
name: "General conversation",
|
|
68
|
+
description: "Send a message and receive a response",
|
|
69
|
+
tags: ["chat"],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Identity helpers ───────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function readAssistantName(): string {
|
|
78
|
+
try {
|
|
79
|
+
const wsDir = getWorkspaceDir();
|
|
80
|
+
const identityPath = join(wsDir, "prompts", "IDENTITY.md");
|
|
81
|
+
if (!existsSync(identityPath)) return "Vellum Assistant";
|
|
82
|
+
const content = readFileSync(identityPath, "utf-8");
|
|
83
|
+
const match = content.match(/\*\*Name:\*\*\s*(.+)/);
|
|
84
|
+
return match?.[1]?.trim() || "Vellum Assistant";
|
|
85
|
+
} catch {
|
|
86
|
+
return "Vellum Assistant";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Route handler factory ──────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export function createAgentCardHandler(configFile: ConfigFileCache) {
|
|
93
|
+
return async (_req: Request): Promise<Response> => {
|
|
94
|
+
const enabled = configFile.getBoolean("a2a", "enabled") ?? false;
|
|
95
|
+
if (!enabled) {
|
|
96
|
+
return Response.json(
|
|
97
|
+
{ error: "A2A channel is not enabled" },
|
|
98
|
+
{ status: 404 },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const publicBaseUrl =
|
|
103
|
+
configFile.getString("ingress", "publicBaseUrl") ?? "";
|
|
104
|
+
if (!publicBaseUrl) {
|
|
105
|
+
log.warn("Agent card requested but no public base URL configured");
|
|
106
|
+
return Response.json(
|
|
107
|
+
{ error: "Public ingress URL not configured" },
|
|
108
|
+
{ status: 503 },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const assistantName = readAssistantName();
|
|
113
|
+
const card = buildAgentCard(publicBaseUrl, assistantName);
|
|
114
|
+
|
|
115
|
+
return Response.json(card, {
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { A2A_AGENT_CARD_PATH };
|
|
@@ -78,14 +78,17 @@ export function createTwilioVoiceVerifyCallbackHandler(
|
|
|
78
78
|
{ callSid, fromNumber },
|
|
79
79
|
"No pending verification session found on callback — forwarding to assistant",
|
|
80
80
|
);
|
|
81
|
-
return forwardToAssistant(
|
|
81
|
+
return forwardToAssistant(
|
|
82
|
+
config,
|
|
83
|
+
params,
|
|
84
|
+
req.url,
|
|
85
|
+
validation.validatedCandidateUrl,
|
|
86
|
+
caches,
|
|
87
|
+
);
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
if (!digits) {
|
|
85
|
-
log.info(
|
|
86
|
-
{ callSid, fromNumber },
|
|
87
|
-
"No digits entered — re-prompting",
|
|
88
|
-
);
|
|
91
|
+
log.info({ callSid, fromNumber }, "No digits entered — re-prompting");
|
|
89
92
|
const actionUrl = buildActionUrl(url, attempt);
|
|
90
93
|
return twimlResponse(
|
|
91
94
|
gatherVerificationTwiml(actionUrl, attempt, session.codeDigits ?? 6),
|
|
@@ -113,7 +116,11 @@ export function createTwilioVoiceVerifyCallbackHandler(
|
|
|
113
116
|
const nextAttempt = attempt + 1;
|
|
114
117
|
const actionUrl = buildActionUrl(url, nextAttempt);
|
|
115
118
|
return twimlResponse(
|
|
116
|
-
gatherVerificationTwiml(
|
|
119
|
+
gatherVerificationTwiml(
|
|
120
|
+
actionUrl,
|
|
121
|
+
nextAttempt,
|
|
122
|
+
session.codeDigits ?? 6,
|
|
123
|
+
),
|
|
117
124
|
);
|
|
118
125
|
}
|
|
119
126
|
|
|
@@ -147,7 +154,10 @@ export function createTwilioVoiceVerifyCallbackHandler(
|
|
|
147
154
|
);
|
|
148
155
|
|
|
149
156
|
const existingGuardian = existingPhoneGuardians[0];
|
|
150
|
-
if (
|
|
157
|
+
if (
|
|
158
|
+
existingGuardian &&
|
|
159
|
+
existingGuardian.externalUserId !== fromNumber
|
|
160
|
+
) {
|
|
151
161
|
log.warn(
|
|
152
162
|
{
|
|
153
163
|
callSid,
|
|
@@ -209,7 +219,13 @@ export function createTwilioVoiceVerifyCallbackHandler(
|
|
|
209
219
|
{ callSid, fromNumber },
|
|
210
220
|
"Voice verification complete — forwarding to assistant for call setup",
|
|
211
221
|
);
|
|
212
|
-
return forwardToAssistant(
|
|
222
|
+
return forwardToAssistant(
|
|
223
|
+
config,
|
|
224
|
+
params,
|
|
225
|
+
req.url,
|
|
226
|
+
validation.validatedCandidateUrl,
|
|
227
|
+
caches,
|
|
228
|
+
);
|
|
213
229
|
};
|
|
214
230
|
}
|
|
215
231
|
|
|
@@ -248,13 +264,17 @@ async function revokeExistingPhoneGuardian(): Promise<void> {
|
|
|
248
264
|
try {
|
|
249
265
|
const gwDb = getGatewayDb();
|
|
250
266
|
for (const id of ids) {
|
|
251
|
-
gwDb
|
|
267
|
+
gwDb
|
|
268
|
+
.update(gwContactChannels)
|
|
252
269
|
.set({ status: "revoked", policy: "deny", updatedAt: now })
|
|
253
270
|
.where(eq(gwContactChannels.id, id))
|
|
254
271
|
.run();
|
|
255
272
|
}
|
|
256
273
|
} catch (gwErr) {
|
|
257
|
-
log.warn(
|
|
274
|
+
log.warn(
|
|
275
|
+
{ err: gwErr },
|
|
276
|
+
"Gateway DB revoke dual-write failed (best-effort)",
|
|
277
|
+
);
|
|
258
278
|
}
|
|
259
279
|
}
|
|
260
280
|
|
|
@@ -276,6 +296,7 @@ async function forwardToAssistant(
|
|
|
276
296
|
config: GatewayConfig,
|
|
277
297
|
params: Record<string, string>,
|
|
278
298
|
originalUrl: string,
|
|
299
|
+
validatedPublicUrl?: string,
|
|
279
300
|
caches?: TwilioValidationCaches,
|
|
280
301
|
): Promise<Response> {
|
|
281
302
|
try {
|
|
@@ -288,7 +309,12 @@ async function forwardToAssistant(
|
|
|
288
309
|
config,
|
|
289
310
|
params,
|
|
290
311
|
originalUrl,
|
|
291
|
-
resolvePublicBaseWssUrl(
|
|
312
|
+
resolvePublicBaseWssUrl(
|
|
313
|
+
config,
|
|
314
|
+
caches?.configFile,
|
|
315
|
+
platformAssistantId,
|
|
316
|
+
validatedPublicUrl,
|
|
317
|
+
),
|
|
292
318
|
);
|
|
293
319
|
return new Response(runtimeResponse.body, {
|
|
294
320
|
status: runtimeResponse.status,
|
|
@@ -304,7 +330,10 @@ async function forwardToAssistant(
|
|
|
304
330
|
},
|
|
305
331
|
);
|
|
306
332
|
}
|
|
307
|
-
log.error(
|
|
333
|
+
log.error(
|
|
334
|
+
{ err },
|
|
335
|
+
"Failed to forward voice webhook to runtime after verification",
|
|
336
|
+
);
|
|
308
337
|
return Response.json({ error: "Internal server error" }, { status: 502 });
|
|
309
338
|
}
|
|
310
339
|
}
|
|
@@ -352,6 +352,61 @@ describe("resolvePublicBaseWssUrl", () => {
|
|
|
352
352
|
);
|
|
353
353
|
});
|
|
354
354
|
|
|
355
|
+
test("uses assistant ID from validated platform callback URL with Velay", () => {
|
|
356
|
+
const config = {
|
|
357
|
+
...baseConfig,
|
|
358
|
+
velayBaseUrl: "https://velay-staging.vellum.ai",
|
|
359
|
+
};
|
|
360
|
+
const result = resolvePublicBaseWssUrl(
|
|
361
|
+
config,
|
|
362
|
+
undefined,
|
|
363
|
+
undefined,
|
|
364
|
+
"https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice/?callSessionId=37b47ade-2eaf-469a-bede-6f2454875e6e",
|
|
365
|
+
);
|
|
366
|
+
expect(result).toBe(
|
|
367
|
+
"wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("prefers validated platform callback URL over stale configFile publicBaseUrl", () => {
|
|
372
|
+
const config = {
|
|
373
|
+
...baseConfig,
|
|
374
|
+
velayBaseUrl: "https://velay-staging.vellum.ai",
|
|
375
|
+
};
|
|
376
|
+
const mockConfigFile = {
|
|
377
|
+
getString: (section: string, key: string) =>
|
|
378
|
+
section === "ingress" && key === "publicBaseUrl"
|
|
379
|
+
? "https://stale-tunnel.example.test"
|
|
380
|
+
: undefined,
|
|
381
|
+
} as Parameters<typeof resolvePublicBaseWssUrl>[1];
|
|
382
|
+
const result = resolvePublicBaseWssUrl(
|
|
383
|
+
config,
|
|
384
|
+
mockConfigFile,
|
|
385
|
+
undefined,
|
|
386
|
+
"https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice?callSessionId=sess-1",
|
|
387
|
+
);
|
|
388
|
+
expect(result).toBe(
|
|
389
|
+
"wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("does not use a platform callback URL as the websocket base", () => {
|
|
394
|
+
const config = {
|
|
395
|
+
...baseConfig,
|
|
396
|
+
velayBaseUrl: "https://velay-staging.vellum.ai",
|
|
397
|
+
};
|
|
398
|
+
const mockConfigFile = {
|
|
399
|
+
getString: (section: string, key: string) =>
|
|
400
|
+
section === "ingress" && key === "publicBaseUrl"
|
|
401
|
+
? "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e"
|
|
402
|
+
: undefined,
|
|
403
|
+
} as Parameters<typeof resolvePublicBaseWssUrl>[1];
|
|
404
|
+
const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined);
|
|
405
|
+
expect(result).toBe(
|
|
406
|
+
"wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
355
410
|
test("strips trailing slash from velayBaseUrl before joining assistant ID", () => {
|
|
356
411
|
const config = {
|
|
357
412
|
...baseConfig,
|
|
@@ -151,7 +151,10 @@ export function createTwilioVoiceWebhookHandler(
|
|
|
151
151
|
// The display name is intentionally included: the caller registered
|
|
152
152
|
// this number themselves, so disclosing their own name is expected.
|
|
153
153
|
const unverifiedStatuses = new Set(["unverified", "pending"]);
|
|
154
|
-
if (
|
|
154
|
+
if (
|
|
155
|
+
callerRecord &&
|
|
156
|
+
unverifiedStatuses.has(callerRecord.channel.status)
|
|
157
|
+
) {
|
|
155
158
|
const isGuardian = callerRecord.contact.role === "guardian";
|
|
156
159
|
log.info(
|
|
157
160
|
{
|
|
@@ -197,7 +200,12 @@ export function createTwilioVoiceWebhookHandler(
|
|
|
197
200
|
config,
|
|
198
201
|
params,
|
|
199
202
|
req.url,
|
|
200
|
-
resolvePublicBaseWssUrl(
|
|
203
|
+
resolvePublicBaseWssUrl(
|
|
204
|
+
config,
|
|
205
|
+
caches?.configFile,
|
|
206
|
+
platformAssistantId,
|
|
207
|
+
validation.validatedCandidateUrl,
|
|
208
|
+
),
|
|
201
209
|
);
|
|
202
210
|
return new Response(runtimeResponse.body, {
|
|
203
211
|
status: runtimeResponse.status,
|
package/src/index.ts
CHANGED
|
@@ -118,6 +118,10 @@ import { createWorkspaceCommitProxyHandler } from "./http/routes/workspace-commi
|
|
|
118
118
|
import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
|
|
119
119
|
import { createLogExportHandler } from "./http/routes/log-export.js";
|
|
120
120
|
import { createLogTailHandler } from "./http/routes/log-tail.js";
|
|
121
|
+
import {
|
|
122
|
+
createAgentCardHandler,
|
|
123
|
+
A2A_AGENT_CARD_PATH,
|
|
124
|
+
} from "./http/routes/a2a-routes.js";
|
|
121
125
|
import {
|
|
122
126
|
createTrustRulesListHandler,
|
|
123
127
|
createTrustRulesCreateHandler,
|
|
@@ -172,6 +176,7 @@ import {
|
|
|
172
176
|
import { GatewayIpcServer } from "./ipc/server.js";
|
|
173
177
|
import { contactRoutes } from "./ipc/contact-handlers.js";
|
|
174
178
|
import { featureFlagRoutes } from "./ipc/feature-flag-handlers.js";
|
|
179
|
+
import { slackThreadRoutes } from "./ipc/slack-thread-handlers.js";
|
|
175
180
|
import { thresholdRoutes } from "./ipc/threshold-handlers.js";
|
|
176
181
|
|
|
177
182
|
import { riskClassificationRoutes } from "./ipc/risk-classification-handlers.js";
|
|
@@ -467,6 +472,8 @@ async function main() {
|
|
|
467
472
|
const handleTrustRulesReset = createTrustRulesResetHandler();
|
|
468
473
|
const handleTrustRulesSuggest = createTrustRulesSuggestHandler();
|
|
469
474
|
|
|
475
|
+
const handleAgentCard = createAgentCardHandler(configFileCache);
|
|
476
|
+
|
|
470
477
|
const audioProxy = createAudioProxyHandler(config);
|
|
471
478
|
|
|
472
479
|
const backupDeps = {
|
|
@@ -505,6 +512,13 @@ async function main() {
|
|
|
505
512
|
// Auth middleware is applied declaratively per route — no manual
|
|
506
513
|
// requireEdgeAuth/wrapWithAuthFailureTracking calls needed.
|
|
507
514
|
const routes: RouteDefinition[] = [
|
|
515
|
+
// ── A2A agent card discovery (read-only, unauthenticated per spec) ──
|
|
516
|
+
{
|
|
517
|
+
path: A2A_AGENT_CARD_PATH,
|
|
518
|
+
method: "GET",
|
|
519
|
+
handler: (req) => handleAgentCard(req),
|
|
520
|
+
},
|
|
521
|
+
|
|
508
522
|
// ── Webhooks (unauthenticated, validated by provider-specific mechanisms) ──
|
|
509
523
|
{
|
|
510
524
|
path: "/webhooks/telegram",
|
|
@@ -2104,6 +2118,7 @@ async function main() {
|
|
|
2104
2118
|
// Fires on initial credential load and whenever vellum credentials change
|
|
2105
2119
|
// (key rotation, late provisioning).
|
|
2106
2120
|
if (changed.has("vellum")) {
|
|
2121
|
+
velayTunnelClient?.refreshCredentials("vellum credentials changed");
|
|
2107
2122
|
registerEmailCallbackRoute({
|
|
2108
2123
|
credentials: credentialCache,
|
|
2109
2124
|
configFile: configFileCache,
|
|
@@ -2191,6 +2206,7 @@ async function main() {
|
|
|
2191
2206
|
const ipcServer = new GatewayIpcServer([
|
|
2192
2207
|
...featureFlagRoutes,
|
|
2193
2208
|
...contactRoutes,
|
|
2209
|
+
...slackThreadRoutes,
|
|
2194
2210
|
...thresholdRoutes,
|
|
2195
2211
|
...riskClassificationRoutes,
|
|
2196
2212
|
...createVelayRoutes(velayTunnelClient),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC route definitions for Slack active-thread listener control.
|
|
3
|
+
*
|
|
4
|
+
* The gateway owns Slack Socket Mode listener state, so assistant-side
|
|
5
|
+
* controls call here instead of writing gateway storage directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import { SlackStore } from "../db/slack-store.js";
|
|
11
|
+
import type { IpcRoute } from "./server.js";
|
|
12
|
+
|
|
13
|
+
let store: SlackStore | null = null;
|
|
14
|
+
|
|
15
|
+
function getStore(): SlackStore {
|
|
16
|
+
if (!store) {
|
|
17
|
+
store = new SlackStore();
|
|
18
|
+
}
|
|
19
|
+
return store;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DetachSlackActiveThreadParamsSchema = z.object({
|
|
23
|
+
channelId: z.string().trim().min(1),
|
|
24
|
+
threadTs: z.string().trim().min(1),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const slackThreadRoutes: IpcRoute[] = [
|
|
28
|
+
{
|
|
29
|
+
method: "detach_slack_active_thread",
|
|
30
|
+
schema: DetachSlackActiveThreadParamsSchema,
|
|
31
|
+
handler: (params?: Record<string, unknown>) => {
|
|
32
|
+
const { channelId, threadTs } = DetachSlackActiveThreadParamsSchema.parse(
|
|
33
|
+
params ?? {},
|
|
34
|
+
);
|
|
35
|
+
const detached = getStore().detachThread(threadTs, channelId);
|
|
36
|
+
return { detached, channelId, threadTs };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
];
|
|
@@ -1035,6 +1035,30 @@ describe("assistant subcommand classification", () => {
|
|
|
1035
1035
|
expect(result.riskLevel).toBe("low");
|
|
1036
1036
|
});
|
|
1037
1037
|
|
|
1038
|
+
test("assistant schedules enable → medium", async () => {
|
|
1039
|
+
const result = await classifier.classify({
|
|
1040
|
+
command: "assistant schedules enable schedule-1",
|
|
1041
|
+
toolName: "bash",
|
|
1042
|
+
});
|
|
1043
|
+
expect(result.riskLevel).toBe("medium");
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test("assistant schedules disable → medium", async () => {
|
|
1047
|
+
const result = await classifier.classify({
|
|
1048
|
+
command: "assistant schedules disable schedule-1",
|
|
1049
|
+
toolName: "bash",
|
|
1050
|
+
});
|
|
1051
|
+
expect(result.riskLevel).toBe("medium");
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test("assistant schedules cancel → medium", async () => {
|
|
1055
|
+
const result = await classifier.classify({
|
|
1056
|
+
command: "assistant schedules cancel schedule-1",
|
|
1057
|
+
toolName: "bash",
|
|
1058
|
+
});
|
|
1059
|
+
expect(result.riskLevel).toBe("medium");
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1038
1062
|
test("assistant schedules execute → medium", async () => {
|
|
1039
1063
|
const result = await classifier.classify({
|
|
1040
1064
|
command: "assistant schedules execute schedule-1",
|
|
@@ -192,6 +192,11 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
|
|
|
192
192
|
"schedules",
|
|
193
193
|
"schedules list",
|
|
194
194
|
"schedules runs",
|
|
195
|
+
"schedules create",
|
|
196
|
+
"schedules enable",
|
|
197
|
+
"schedules disable",
|
|
198
|
+
"schedules cancel",
|
|
199
|
+
"schedules delete",
|
|
195
200
|
"schedules execute",
|
|
196
201
|
"sequence",
|
|
197
202
|
"sequence list",
|
|
@@ -261,6 +266,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
|
|
|
261
266
|
"plugins",
|
|
262
267
|
"plugins install",
|
|
263
268
|
"plugins list",
|
|
269
|
+
"plugins search",
|
|
264
270
|
"plugins uninstall",
|
|
265
271
|
] as const;
|
|
266
272
|
|
|
@@ -499,6 +505,33 @@ const riskOverrides: AssistantRiskOverride[] = [
|
|
|
499
505
|
{ path: "platform connect", risk: "low" },
|
|
500
506
|
{ path: "platform disconnect", risk: "medium" },
|
|
501
507
|
{ path: "platform callback-routes register", risk: "low" },
|
|
508
|
+
{
|
|
509
|
+
path: "schedules create",
|
|
510
|
+
risk: "medium",
|
|
511
|
+
reason:
|
|
512
|
+
"Creates a new recurring schedule that fires assistant-side messages",
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
path: "schedules enable",
|
|
516
|
+
risk: "medium",
|
|
517
|
+
reason: "Enables a schedule and mutates assistant schedule state",
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
path: "schedules disable",
|
|
521
|
+
risk: "medium",
|
|
522
|
+
reason: "Disables a schedule and mutates assistant schedule state",
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
path: "schedules cancel",
|
|
526
|
+
risk: "medium",
|
|
527
|
+
reason: "Cancels a pending schedule and mutates assistant schedule state",
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
path: "schedules delete",
|
|
531
|
+
risk: "medium",
|
|
532
|
+
reason:
|
|
533
|
+
"Permanently removes a schedule and its run history from assistant state",
|
|
534
|
+
},
|
|
502
535
|
{
|
|
503
536
|
path: "schedules execute",
|
|
504
537
|
risk: "medium",
|
|
@@ -599,6 +599,11 @@ describe("command-registry", () => {
|
|
|
599
599
|
expect(getAssistantPath("inference session list").baseRisk).toBe("low");
|
|
600
600
|
expect(getAssistantPath("schedules list").baseRisk).toBe("low");
|
|
601
601
|
expect(getAssistantPath("schedules runs").baseRisk).toBe("low");
|
|
602
|
+
expect(getAssistantPath("schedules create").baseRisk).toBe("medium");
|
|
603
|
+
expect(getAssistantPath("schedules enable").baseRisk).toBe("medium");
|
|
604
|
+
expect(getAssistantPath("schedules disable").baseRisk).toBe("medium");
|
|
605
|
+
expect(getAssistantPath("schedules cancel").baseRisk).toBe("medium");
|
|
606
|
+
expect(getAssistantPath("schedules delete").baseRisk).toBe("medium");
|
|
602
607
|
expect(getAssistantPath("schedules execute").baseRisk).toBe("medium");
|
|
603
608
|
});
|
|
604
609
|
});
|