@vellumai/vellum-gateway 0.8.2 → 0.8.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 +1 -1
- package/package.json +1 -1
- package/src/__tests__/config-file-watcher.test.ts +57 -0
- package/src/__tests__/route-schema-guard.test.ts +4 -0
- package/src/__tests__/twilio-webhooks.test.ts +47 -0
- package/src/auth/ipc-route-policy.ts +6 -0
- package/src/channels/inbound-event.ts +4 -2
- package/src/channels/types.ts +2 -0
- package/src/config-file-watcher.ts +44 -1
- package/src/feature-flag-registry.json +21 -29
- 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 +14 -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/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
package/ARCHITECTURE.md
CHANGED
|
@@ -298,7 +298,7 @@ The assistant runtime reads this URL via the centralized `public-ingress-urls.ts
|
|
|
298
298
|
|
|
299
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 creates `VelayTunnelClient` but starts it only after Twilio setup has been started in the workspace. On boot, existing Twilio credentials or existing `twilio.accountSid` / `twilio.phoneNumber` config count as prior setup
|
|
301
|
+
When `VELAY_BASE_URL` is present in the gateway environment, the gateway creates `VelayTunnelClient` but starts it only after Twilio setup has been started in the workspace. The Twilio setup skill writes `twilio.setupStarted: true` at the beginning of setup so the tunnel can open while credentials and phone-number selection are still in progress. On boot, existing Twilio credentials or existing `twilio.accountSid` / `twilio.phoneNumber` config also count as prior setup. Before credential-backed startup side effects run, the gateway clears any stale Velay-managed `ingress.publicBaseUrl`; if setup has not started, it does this without opening a tunnel. 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>`. If Vellum platform credentials change while the tunnel is already running or waiting on backoff, the gateway asks the client to reconnect with fresh credentials instead of waiting for process restart. The gateway writes the registered 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
|
|
package/package.json
CHANGED
|
@@ -42,6 +42,31 @@ function makeEvent(
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function makeManualIntervalTimer() {
|
|
46
|
+
let intervalFn: (() => void) | undefined;
|
|
47
|
+
let cleared = false;
|
|
48
|
+
type IntervalHandle = ReturnType<typeof setInterval>;
|
|
49
|
+
const timer = 1 as unknown as IntervalHandle;
|
|
50
|
+
return {
|
|
51
|
+
timerApi: {
|
|
52
|
+
setInterval: (fn: () => void, _delayMs: number) => {
|
|
53
|
+
intervalFn = fn;
|
|
54
|
+
return timer;
|
|
55
|
+
},
|
|
56
|
+
clearInterval: (_timer: IntervalHandle) => {
|
|
57
|
+
cleared = true;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
runInterval: () => {
|
|
61
|
+
if (!intervalFn) {
|
|
62
|
+
throw new Error("interval was not scheduled");
|
|
63
|
+
}
|
|
64
|
+
intervalFn();
|
|
65
|
+
},
|
|
66
|
+
isCleared: () => cleared,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
afterEach(() => {
|
|
46
71
|
try {
|
|
47
72
|
if (existsSync(configPath)) unlinkSync(configPath);
|
|
@@ -51,6 +76,38 @@ afterEach(() => {
|
|
|
51
76
|
});
|
|
52
77
|
|
|
53
78
|
describe("ConfigFileWatcher", () => {
|
|
79
|
+
test("polls config changes when file watcher events are missed", () => {
|
|
80
|
+
const events: ConfigChangeEvent[] = [];
|
|
81
|
+
const timer = makeManualIntervalTimer();
|
|
82
|
+
const watcher = new ConfigFileWatcher(
|
|
83
|
+
(event) => {
|
|
84
|
+
events.push(event);
|
|
85
|
+
},
|
|
86
|
+
{ pollIntervalMs: 10, timerApi: timer.timerApi },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
watcher.start();
|
|
91
|
+
writeConfig({
|
|
92
|
+
twilio: {
|
|
93
|
+
setupStarted: true,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
timer.runInterval();
|
|
98
|
+
|
|
99
|
+
expect(events).toHaveLength(1);
|
|
100
|
+
expect(events[0].changedKeys).toEqual(new Set(["twilio"]));
|
|
101
|
+
expect(events[0].changedFields.get("twilio")).toEqual(
|
|
102
|
+
new Set(["setupStarted"]),
|
|
103
|
+
);
|
|
104
|
+
} finally {
|
|
105
|
+
watcher.stop();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
expect(timer.isCleared()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
54
111
|
test("reports shallow ingress fields changed by Velay-managed URL writes", () => {
|
|
55
112
|
writeConfig({
|
|
56
113
|
ingress: {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TWILIO_STATUS_WEBHOOK_PATH,
|
|
9
9
|
TWILIO_VOICE_WEBHOOK_PATH,
|
|
10
10
|
} from "@vellumai/service-contracts/twilio-ingress";
|
|
11
|
+
import { A2A_AGENT_CARD_PATH } from "../http/routes/a2a-routes.js";
|
|
11
12
|
import { buildSchema } from "../schema.js";
|
|
12
13
|
|
|
13
14
|
/** A route extracted from source: path + optional HTTP method. */
|
|
@@ -17,6 +18,7 @@ interface ExtractedRoute {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const ROUTE_PATH_CONSTANTS: Record<string, string> = {
|
|
21
|
+
A2A_AGENT_CARD_PATH,
|
|
20
22
|
TWILIO_CONNECT_ACTION_WEBHOOK_PATH,
|
|
21
23
|
TWILIO_MEDIA_STREAM_WEBHOOK_PATH,
|
|
22
24
|
TWILIO_RELAY_WEBHOOK_PATH,
|
|
@@ -169,6 +171,8 @@ const EXCLUDED_FROM_SCHEMA = new Set([
|
|
|
169
171
|
"catch-all",
|
|
170
172
|
// Loopback-only pairing endpoint — not part of the public gateway API
|
|
171
173
|
"/v1/pair",
|
|
174
|
+
// A2A agent card discovery — read-only, unauthenticated per spec
|
|
175
|
+
"/.well-known/agent-card.json",
|
|
172
176
|
]);
|
|
173
177
|
|
|
174
178
|
// ── Schema paths that don't map to a discrete route definition ──
|
|
@@ -826,6 +826,53 @@ describe("Twilio webhook signature with canonical ingress base URL", () => {
|
|
|
826
826
|
});
|
|
827
827
|
});
|
|
828
828
|
|
|
829
|
+
test("uses platform callback assistant ID for Twilio websocket URL", async () => {
|
|
830
|
+
const assistantId = "019e2d0d-f355-744c-a12c-d7e7dcefcf1e";
|
|
831
|
+
fetchMock = mock(
|
|
832
|
+
async () =>
|
|
833
|
+
new Response(
|
|
834
|
+
'<?xml version="1.0" encoding="UTF-8"?><Response><Connect>' +
|
|
835
|
+
'<ConversationRelay url="wss://__VELLUM_PUBLIC_BASE_URL__/webhooks/twilio/relay?token=__VELLUM_RELAY_TOKEN__"/>' +
|
|
836
|
+
"</Connect></Response>",
|
|
837
|
+
{
|
|
838
|
+
status: 200,
|
|
839
|
+
headers: { "Content-Type": "text/xml" },
|
|
840
|
+
},
|
|
841
|
+
),
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
const handler = createTwilioVoiceWebhookHandler(
|
|
845
|
+
makeConfig({ velayBaseUrl: "https://velay-staging.vellum.ai" }),
|
|
846
|
+
makeCaches(),
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
const platformCallbackUrl =
|
|
850
|
+
`https://staging-platform.vellum.ai/v1/gateway/callbacks/${assistantId}` +
|
|
851
|
+
"/webhooks/twilio/voice?callSessionId=platform-wss-test";
|
|
852
|
+
const localUrl =
|
|
853
|
+
"http://localhost:7830/webhooks/twilio/voice?callSessionId=platform-wss-test";
|
|
854
|
+
const params = { CallSid: "CA-platform-wss" };
|
|
855
|
+
const signature = computeSignature(platformCallbackUrl, params, AUTH_TOKEN);
|
|
856
|
+
const req = new Request(localUrl, {
|
|
857
|
+
method: "POST",
|
|
858
|
+
headers: {
|
|
859
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
860
|
+
"X-Twilio-Signature": signature,
|
|
861
|
+
"X-Vellum-Ingress-URL": platformCallbackUrl,
|
|
862
|
+
},
|
|
863
|
+
body: new URLSearchParams(params).toString(),
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const res = await handler(req);
|
|
867
|
+
expect(res.status).toBe(200);
|
|
868
|
+
const body = await res.text();
|
|
869
|
+
expect(body).toContain(
|
|
870
|
+
`url="wss://velay-staging.vellum.ai/${assistantId}/webhooks/twilio/relay?`,
|
|
871
|
+
);
|
|
872
|
+
expect(body).not.toContain("__VELLUM_PUBLIC_BASE_URL__");
|
|
873
|
+
expect(body).not.toContain("staging-platform.vellum.ai");
|
|
874
|
+
});
|
|
875
|
+
|
|
829
876
|
test("platform proxy URL takes priority over configured ingress", async () => {
|
|
830
877
|
fetchMock = mock(
|
|
831
878
|
async () =>
|
|
@@ -223,6 +223,12 @@ const POLICY_TABLE: PolicyEntry[] = [
|
|
|
223
223
|
["updateHeartbeatConfig", ["settings.write"]],
|
|
224
224
|
|
|
225
225
|
// Integrations / ingress
|
|
226
|
+
[
|
|
227
|
+
"integrations_a2a_invite_complete_post",
|
|
228
|
+
["internal.write"],
|
|
229
|
+
["svc_gateway"],
|
|
230
|
+
],
|
|
231
|
+
["integrations_a2a_invite_redeem_post", ["internal.write"], ["svc_gateway"]],
|
|
226
232
|
["integrations_ingress_config_get", ["settings.read"]],
|
|
227
233
|
["integrations_ingress_config_put", ["settings.write"]],
|
|
228
234
|
["integrations_oauth_start_post", ["settings.write"]],
|
|
@@ -10,7 +10,7 @@ import type { ChannelId } from "./types.js";
|
|
|
10
10
|
|
|
11
11
|
export type InboundChannelId = Extract<
|
|
12
12
|
ChannelId,
|
|
13
|
-
"telegram" | "whatsapp" | "slack" | "email"
|
|
13
|
+
"telegram" | "whatsapp" | "slack" | "email" | "a2a"
|
|
14
14
|
>;
|
|
15
15
|
|
|
16
16
|
interface InboundEventBase<C extends InboundChannelId> {
|
|
@@ -59,9 +59,11 @@ export type TelegramInboundEvent = InboundEventBase<"telegram">;
|
|
|
59
59
|
export type WhatsAppInboundEvent = InboundEventBase<"whatsapp">;
|
|
60
60
|
export type SlackInboundEvent = InboundEventBase<"slack">;
|
|
61
61
|
export type EmailInboundEvent = InboundEventBase<"email">;
|
|
62
|
+
export type A2aInboundEvent = InboundEventBase<"a2a">;
|
|
62
63
|
|
|
63
64
|
export type GatewayInboundEvent =
|
|
64
65
|
| TelegramInboundEvent
|
|
65
66
|
| WhatsAppInboundEvent
|
|
66
67
|
| SlackInboundEvent
|
|
67
|
-
| EmailInboundEvent
|
|
68
|
+
| EmailInboundEvent
|
|
69
|
+
| A2aInboundEvent;
|
package/src/channels/types.ts
CHANGED
|
@@ -5,6 +5,7 @@ export const CHANNEL_IDS = [
|
|
|
5
5
|
"whatsapp",
|
|
6
6
|
"slack",
|
|
7
7
|
"email",
|
|
8
|
+
"a2a",
|
|
8
9
|
] as const;
|
|
9
10
|
|
|
10
11
|
export type ChannelId = (typeof CHANNEL_IDS)[number];
|
|
@@ -30,6 +31,7 @@ export const INTERFACE_IDS = [
|
|
|
30
31
|
"whatsapp",
|
|
31
32
|
"slack",
|
|
32
33
|
"email",
|
|
34
|
+
"a2a",
|
|
33
35
|
] as const;
|
|
34
36
|
|
|
35
37
|
export type InterfaceId = (typeof INTERFACE_IDS)[number];
|
|
@@ -15,6 +15,14 @@ import { getLogger } from "./logger.js";
|
|
|
15
15
|
const log = getLogger("config-file-watcher");
|
|
16
16
|
|
|
17
17
|
const DEBOUNCE_MS = 500;
|
|
18
|
+
const FALLBACK_POLL_MS = 1_000;
|
|
19
|
+
|
|
20
|
+
type IntervalHandle = ReturnType<typeof setInterval>;
|
|
21
|
+
|
|
22
|
+
type TimerApi = {
|
|
23
|
+
setInterval: (fn: () => void, delayMs: number) => IntervalHandle;
|
|
24
|
+
clearInterval: (timer: IntervalHandle) => void;
|
|
25
|
+
};
|
|
18
26
|
|
|
19
27
|
export type ConfigChangeEvent = {
|
|
20
28
|
/** Full parsed config.json data. */
|
|
@@ -32,16 +40,27 @@ export type ConfigChangeCallback = (event: ConfigChangeEvent) => void;
|
|
|
32
40
|
|
|
33
41
|
export class ConfigFileWatcher {
|
|
34
42
|
private watcher: FSWatcher | null = null;
|
|
43
|
+
private pollTimer: IntervalHandle | null = null;
|
|
35
44
|
private watchingDirectory = false;
|
|
36
45
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
37
46
|
private lastSerialized: Map<string, string> = new Map();
|
|
38
47
|
private lastValues: Map<string, unknown> = new Map();
|
|
39
48
|
private callback: ConfigChangeCallback;
|
|
40
49
|
private configPath: string;
|
|
50
|
+
private pollIntervalMs: number;
|
|
51
|
+
private timerApi: TimerApi;
|
|
41
52
|
|
|
42
|
-
constructor(
|
|
53
|
+
constructor(
|
|
54
|
+
callback: ConfigChangeCallback,
|
|
55
|
+
opts?: { pollIntervalMs?: number; timerApi?: TimerApi },
|
|
56
|
+
) {
|
|
43
57
|
this.callback = callback;
|
|
44
58
|
this.configPath = getConfigPath();
|
|
59
|
+
this.pollIntervalMs = opts?.pollIntervalMs ?? FALLBACK_POLL_MS;
|
|
60
|
+
this.timerApi = opts?.timerApi ?? {
|
|
61
|
+
setInterval,
|
|
62
|
+
clearInterval,
|
|
63
|
+
};
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
start(): void {
|
|
@@ -77,6 +96,8 @@ export class ConfigFileWatcher {
|
|
|
77
96
|
"Failed to start config file watcher",
|
|
78
97
|
);
|
|
79
98
|
}
|
|
99
|
+
|
|
100
|
+
this.startFallbackPolling();
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
stop(): void {
|
|
@@ -84,12 +105,34 @@ export class ConfigFileWatcher {
|
|
|
84
105
|
clearTimeout(this.debounceTimer);
|
|
85
106
|
this.debounceTimer = null;
|
|
86
107
|
}
|
|
108
|
+
if (this.pollTimer) {
|
|
109
|
+
this.timerApi.clearInterval(this.pollTimer);
|
|
110
|
+
this.pollTimer = null;
|
|
111
|
+
}
|
|
87
112
|
if (this.watcher) {
|
|
88
113
|
this.watcher.close();
|
|
89
114
|
this.watcher = null;
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
|
|
118
|
+
private startFallbackPolling(): void {
|
|
119
|
+
if (this.pollIntervalMs <= 0 || this.pollTimer) return;
|
|
120
|
+
|
|
121
|
+
this.pollTimer = this.timerApi.setInterval(() => {
|
|
122
|
+
this.pollOnce();
|
|
123
|
+
|
|
124
|
+
if (this.watchingDirectory && existsSync(this.configPath)) {
|
|
125
|
+
this.upgradeWatcher();
|
|
126
|
+
}
|
|
127
|
+
}, this.pollIntervalMs);
|
|
128
|
+
(this.pollTimer as { unref?: () => void }).unref?.();
|
|
129
|
+
|
|
130
|
+
log.info(
|
|
131
|
+
{ intervalMs: this.pollIntervalMs },
|
|
132
|
+
"Polling config file for missed change events",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
private scheduleCheck(): void {
|
|
94
137
|
if (this.debounceTimer) {
|
|
95
138
|
clearTimeout(this.debounceTimer);
|
|
@@ -9,14 +9,6 @@
|
|
|
9
9
|
"description": "Automatically trigger conversation analysis on the same cadence as memory extraction (batch threshold, idle debounce, end-of-conversation). The analysis agent has full tool access and writes back to memory and skills without user approval.",
|
|
10
10
|
"defaultEnabled": false
|
|
11
11
|
},
|
|
12
|
-
{
|
|
13
|
-
"id": "memory-retrospective",
|
|
14
|
-
"scope": "assistant",
|
|
15
|
-
"key": "memory-retrospective",
|
|
16
|
-
"label": "Memory retrospective pass",
|
|
17
|
-
"description": "Run a focused, memory-only retrospective during active conversations and at conversation lifecycle. The retrospective agent re-reads the messages added since the last successful run, dedupes against memory/archive/, and calls `remember` for what wasn't captured in the moment. Enabling this also relaxes the in-conversation pressure to call `remember` (lighter PKB reminder + relaxed tool description) so the assistant can stay present and trust the retrospective as the backstop.",
|
|
18
|
-
"defaultEnabled": false
|
|
19
|
-
},
|
|
20
12
|
{
|
|
21
13
|
"id": "user-hosted-enabled",
|
|
22
14
|
"scope": "client",
|
|
@@ -33,6 +25,14 @@
|
|
|
33
25
|
"description": "When enabled, the Local hosting option uses Docker under the hood for sandboxed execution, hiding the separate Docker card",
|
|
34
26
|
"defaultEnabled": false
|
|
35
27
|
},
|
|
28
|
+
{
|
|
29
|
+
"id": "a2a-channel",
|
|
30
|
+
"scope": "assistant",
|
|
31
|
+
"key": "a2a-channel",
|
|
32
|
+
"label": "A2A Channel",
|
|
33
|
+
"description": "Enable the A2A (Agent-to-Agent) channel for inter-assistant communication via the open A2A protocol",
|
|
34
|
+
"defaultEnabled": false
|
|
35
|
+
},
|
|
36
36
|
{
|
|
37
37
|
"id": "email-channel",
|
|
38
38
|
"scope": "assistant",
|
|
@@ -145,6 +145,14 @@
|
|
|
145
145
|
"description": "Show a speaker button on assistant messages to generate and play the message as audio via Fish Audio TTS",
|
|
146
146
|
"defaultEnabled": false
|
|
147
147
|
},
|
|
148
|
+
{
|
|
149
|
+
"id": "openai-compatible-endpoints",
|
|
150
|
+
"scope": "assistant",
|
|
151
|
+
"key": "openai-compatible-endpoints",
|
|
152
|
+
"label": "OpenAI-Compatible Endpoints",
|
|
153
|
+
"description": "Enable user-configured OpenAI-compatible inference endpoints with custom base URLs and model identifiers",
|
|
154
|
+
"defaultEnabled": false
|
|
155
|
+
},
|
|
148
156
|
{
|
|
149
157
|
"id": "backward-releases",
|
|
150
158
|
"scope": "assistant",
|
|
@@ -281,14 +289,6 @@
|
|
|
281
289
|
"description": "Enable the app-control skill (per-app screenshot + raw input bypassing AX tree)",
|
|
282
290
|
"defaultEnabled": false
|
|
283
291
|
},
|
|
284
|
-
{
|
|
285
|
-
"id": "species-migration",
|
|
286
|
-
"scope": "assistant",
|
|
287
|
-
"key": "species-migration",
|
|
288
|
-
"label": "Species Migration",
|
|
289
|
-
"description": "Enable the Species Migration skill for migrating from OpenClaw, Hermes, Manus, and other assistant species into Vellum.",
|
|
290
|
-
"defaultEnabled": false
|
|
291
|
-
},
|
|
292
292
|
{
|
|
293
293
|
"id": "analyze-conversation",
|
|
294
294
|
"scope": "assistant",
|
|
@@ -322,19 +322,11 @@
|
|
|
322
322
|
"defaultEnabled": false
|
|
323
323
|
},
|
|
324
324
|
{
|
|
325
|
-
"id": "
|
|
326
|
-
"scope": "
|
|
327
|
-
"key": "
|
|
328
|
-
"label": "
|
|
329
|
-
"description": "
|
|
330
|
-
"defaultEnabled": false
|
|
331
|
-
},
|
|
332
|
-
{
|
|
333
|
-
"id": "provider-minimax",
|
|
334
|
-
"scope": "assistant",
|
|
335
|
-
"key": "provider-minimax",
|
|
336
|
-
"label": "MiniMax Provider",
|
|
337
|
-
"description": "Enable the MiniMax direct API provider and its models (M2.7, M2.5, M2.1, M2, and highspeed variants) in the provider picker and model selection UI",
|
|
325
|
+
"id": "velvet-theme",
|
|
326
|
+
"scope": "client",
|
|
327
|
+
"key": "velvet-theme",
|
|
328
|
+
"label": "Velvet Theme",
|
|
329
|
+
"description": "Show the Velvet theme option in the macOS appearance settings. Velvet is a dark-mode variant with red/pink accent colors.",
|
|
338
330
|
"defaultEnabled": false
|
|
339
331
|
}
|
|
340
332
|
]
|
|
@@ -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 };
|