@vellumai/assistant 0.11.5 → 0.11.6-staging.1
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 +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the tunnel reachability probe.
|
|
3
|
+
*
|
|
4
|
+
* Four properties carry the feature. First, `/healthz` decides liveness, so an
|
|
5
|
+
* edge whose nginx answers while the gateway behind it does not reads
|
|
6
|
+
* `unreachable`. Second, the config path decides pairing: an ingress that
|
|
7
|
+
* answers it with anything but the pairing edge's own marked config reads
|
|
8
|
+
* `unpairable`, while a config request that gets no answer at all leaves the
|
|
9
|
+
* liveness verdict standing. Third, `foreign` is an accusation ("this URL now
|
|
10
|
+
* fronts someone else's assistant"), so it is reserved for a positive mismatch
|
|
11
|
+
* of two known ids and every skew case reads `healthy`. Fourth, the probe runs
|
|
12
|
+
* often enough that a body it never parses has to be cancelled rather than
|
|
13
|
+
* left to GC.
|
|
14
|
+
*
|
|
15
|
+
* Every case injects `fetchImpl`, so nothing here touches the network.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from "bun:test";
|
|
19
|
+
|
|
20
|
+
import { probeTunnel } from "../tunnel-probe.js";
|
|
21
|
+
|
|
22
|
+
const BASE = "https://edge.example";
|
|
23
|
+
|
|
24
|
+
/** The marker the nginx pairing edge stamps into every config it serves. */
|
|
25
|
+
const PAIRING_EDGE_MODE = "remote-gateway";
|
|
26
|
+
|
|
27
|
+
type Route = { status?: number; body?: string; reject?: unknown };
|
|
28
|
+
|
|
29
|
+
/** `typeof fetch` carries extras (e.g. `preconnect`) a stub has no use for. */
|
|
30
|
+
function asFetch(
|
|
31
|
+
stub: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
|
32
|
+
): typeof fetch {
|
|
33
|
+
return stub as unknown as typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A hand-rolled response rather than a real one, so `body.cancel()` is
|
|
38
|
+
* observable and stays callable after a failed `json()`.
|
|
39
|
+
*/
|
|
40
|
+
function stubResponse(route: Route, onCancel: () => void): Response {
|
|
41
|
+
const status = route.status ?? 200;
|
|
42
|
+
const text = route.body ?? "";
|
|
43
|
+
return {
|
|
44
|
+
ok: status >= 200 && status < 300,
|
|
45
|
+
status,
|
|
46
|
+
body: {
|
|
47
|
+
cancel: async (): Promise<void> => {
|
|
48
|
+
onCancel();
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
json: async (): Promise<unknown> => JSON.parse(text),
|
|
52
|
+
} as unknown as Response;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Serves canned responses per path, recording requests and body cancels. */
|
|
56
|
+
function stubFetch(routes: { healthz?: Route; config?: Route }): {
|
|
57
|
+
fetchImpl: typeof fetch;
|
|
58
|
+
urls: string[];
|
|
59
|
+
cancelled: string[];
|
|
60
|
+
} {
|
|
61
|
+
const urls: string[] = [];
|
|
62
|
+
const cancelled: string[] = [];
|
|
63
|
+
const fetchImpl = asFetch(async (input) => {
|
|
64
|
+
const url = String(input);
|
|
65
|
+
urls.push(url);
|
|
66
|
+
const isHealth = url.endsWith("/healthz");
|
|
67
|
+
const label = isHealth ? "healthz" : "config";
|
|
68
|
+
const route = isHealth
|
|
69
|
+
? (routes.healthz ?? { body: "ok" })
|
|
70
|
+
: (routes.config ?? { body: JSON.stringify({ mode: PAIRING_EDGE_MODE }) });
|
|
71
|
+
if (route.reject !== undefined) {
|
|
72
|
+
throw route.reject;
|
|
73
|
+
}
|
|
74
|
+
return stubResponse(route, () => cancelled.push(label));
|
|
75
|
+
});
|
|
76
|
+
return { fetchImpl, urls, cancelled };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The probe accepts a served config only from an edge that marks itself, so a
|
|
81
|
+
* case about identity alone carries the marker without saying so each time.
|
|
82
|
+
*/
|
|
83
|
+
function configBody(config: Record<string, unknown>): Route {
|
|
84
|
+
return {
|
|
85
|
+
status: 200,
|
|
86
|
+
body: JSON.stringify({ mode: PAIRING_EDGE_MODE, ...config }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("probeTunnel", () => {
|
|
91
|
+
test("probes both the gateway path and the served config", async () => {
|
|
92
|
+
const { fetchImpl, urls } = stubFetch({});
|
|
93
|
+
await probeTunnel({ publicBaseUrl: `${BASE}/`, fetchImpl });
|
|
94
|
+
expect(urls.sort()).toEqual([
|
|
95
|
+
`${BASE}/assistant/__config`,
|
|
96
|
+
`${BASE}/healthz`,
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("healthy when the served id matches the recorded one", async () => {
|
|
101
|
+
const { fetchImpl } = stubFetch({
|
|
102
|
+
config: configBody({ assistantId: "asst_1", assistantName: "Ada" }),
|
|
103
|
+
});
|
|
104
|
+
await expect(
|
|
105
|
+
probeTunnel({
|
|
106
|
+
publicBaseUrl: BASE,
|
|
107
|
+
expectedAssistantId: "asst_1",
|
|
108
|
+
fetchImpl,
|
|
109
|
+
}),
|
|
110
|
+
).resolves.toEqual({
|
|
111
|
+
kind: "healthy",
|
|
112
|
+
assistantId: "asst_1",
|
|
113
|
+
assistantName: "Ada",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("healthy when the edge serves no id (edge predates the id)", async () => {
|
|
118
|
+
const { fetchImpl } = stubFetch({
|
|
119
|
+
config: configBody({ assistantName: "Ada" }),
|
|
120
|
+
});
|
|
121
|
+
await expect(
|
|
122
|
+
probeTunnel({
|
|
123
|
+
publicBaseUrl: BASE,
|
|
124
|
+
expectedAssistantId: "asst_1",
|
|
125
|
+
fetchImpl,
|
|
126
|
+
}),
|
|
127
|
+
).resolves.toEqual({
|
|
128
|
+
kind: "healthy",
|
|
129
|
+
assistantId: undefined,
|
|
130
|
+
assistantName: "Ada",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("healthy when no id was recorded, even though the edge serves one", async () => {
|
|
135
|
+
const { fetchImpl } = stubFetch({
|
|
136
|
+
config: configBody({ assistantId: "asst_2", assistantName: "Grace" }),
|
|
137
|
+
});
|
|
138
|
+
await expect(
|
|
139
|
+
probeTunnel({ publicBaseUrl: BASE, fetchImpl }),
|
|
140
|
+
).resolves.toEqual({
|
|
141
|
+
kind: "healthy",
|
|
142
|
+
assistantId: "asst_2",
|
|
143
|
+
assistantName: "Grace",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("foreign when two known ids differ", async () => {
|
|
148
|
+
const { fetchImpl } = stubFetch({
|
|
149
|
+
config: configBody({ assistantId: "asst_2", assistantName: "Grace" }),
|
|
150
|
+
});
|
|
151
|
+
await expect(
|
|
152
|
+
probeTunnel({
|
|
153
|
+
publicBaseUrl: BASE,
|
|
154
|
+
expectedAssistantId: "asst_1",
|
|
155
|
+
fetchImpl,
|
|
156
|
+
}),
|
|
157
|
+
).resolves.toEqual({
|
|
158
|
+
kind: "foreign",
|
|
159
|
+
assistantId: "asst_2",
|
|
160
|
+
assistantName: "Grace",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("healthy when the served id is padded with whitespace", async () => {
|
|
165
|
+
// The recorded id is read through a trimming parser, so a served id that
|
|
166
|
+
// only differs by padding is the same assistant, not a foreign one.
|
|
167
|
+
const { fetchImpl } = stubFetch({
|
|
168
|
+
config: configBody({ assistantId: " asst_1 ", assistantName: " Ada " }),
|
|
169
|
+
});
|
|
170
|
+
await expect(
|
|
171
|
+
probeTunnel({
|
|
172
|
+
publicBaseUrl: BASE,
|
|
173
|
+
expectedAssistantId: " asst_1 ",
|
|
174
|
+
fetchImpl,
|
|
175
|
+
}),
|
|
176
|
+
).resolves.toEqual({
|
|
177
|
+
kind: "healthy",
|
|
178
|
+
assistantId: "asst_1",
|
|
179
|
+
assistantName: "Ada",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("unpairable when the config path answers with something else", async () => {
|
|
184
|
+
// An SPA edge serves JSON here. Anything else means whatever is answering
|
|
185
|
+
// is not the app that serves `/assistant/pair` either.
|
|
186
|
+
const { fetchImpl } = stubFetch({
|
|
187
|
+
config: { status: 200, body: "<html>nope</html>" },
|
|
188
|
+
});
|
|
189
|
+
await expect(
|
|
190
|
+
probeTunnel({
|
|
191
|
+
publicBaseUrl: BASE,
|
|
192
|
+
expectedAssistantId: "asst_1",
|
|
193
|
+
fetchImpl,
|
|
194
|
+
}),
|
|
195
|
+
).resolves.toEqual({
|
|
196
|
+
kind: "unpairable",
|
|
197
|
+
detail: "no assistant config served",
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("unpairable when a served body carries no pairing-edge marker", async () => {
|
|
202
|
+
// A bring-your-own front or a catch-all that answers every path with some
|
|
203
|
+
// JSON is not the edge serving `/assistant/pair`, however well-formed its
|
|
204
|
+
// body is.
|
|
205
|
+
const { fetchImpl, cancelled } = stubFetch({
|
|
206
|
+
config: { status: 200, body: "{}" },
|
|
207
|
+
});
|
|
208
|
+
await expect(
|
|
209
|
+
probeTunnel({
|
|
210
|
+
publicBaseUrl: BASE,
|
|
211
|
+
expectedAssistantId: "asst_1",
|
|
212
|
+
fetchImpl,
|
|
213
|
+
}),
|
|
214
|
+
).resolves.toEqual({
|
|
215
|
+
kind: "unpairable",
|
|
216
|
+
detail: "not a pairing edge config",
|
|
217
|
+
});
|
|
218
|
+
expect(cancelled.sort()).toEqual(["config", "healthz"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("healthy when the marked pairing edge identifies this assistant", async () => {
|
|
222
|
+
// The whole served document, as `remoteWebIngressConfig` writes it.
|
|
223
|
+
const { fetchImpl } = stubFetch({
|
|
224
|
+
config: {
|
|
225
|
+
status: 200,
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
mode: PAIRING_EDGE_MODE,
|
|
228
|
+
apiBaseUrl: "/v1",
|
|
229
|
+
platformDisabled: true,
|
|
230
|
+
disablePlatform: true,
|
|
231
|
+
assistantName: "Ada",
|
|
232
|
+
assistantId: "asst_1",
|
|
233
|
+
hubUrl: "https://app.vellum.ai",
|
|
234
|
+
}),
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
await expect(
|
|
238
|
+
probeTunnel({
|
|
239
|
+
publicBaseUrl: BASE,
|
|
240
|
+
expectedAssistantId: "asst_1",
|
|
241
|
+
fetchImpl,
|
|
242
|
+
}),
|
|
243
|
+
).resolves.toEqual({
|
|
244
|
+
kind: "healthy",
|
|
245
|
+
assistantId: "asst_1",
|
|
246
|
+
assistantName: "Ada",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("unpairable when the ingress does not serve the config path at all", async () => {
|
|
251
|
+
// A tunnel pointed straight at the gateway: `/healthz` is the gateway's
|
|
252
|
+
// own, and no pairing app sits in front of it.
|
|
253
|
+
const { fetchImpl } = stubFetch({
|
|
254
|
+
config: { status: 404, body: "not found" },
|
|
255
|
+
});
|
|
256
|
+
await expect(
|
|
257
|
+
probeTunnel({
|
|
258
|
+
publicBaseUrl: BASE,
|
|
259
|
+
expectedAssistantId: "asst_1",
|
|
260
|
+
fetchImpl,
|
|
261
|
+
}),
|
|
262
|
+
).resolves.toEqual({ kind: "unpairable", detail: "HTTP 404" });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("healthy when the config request rejects but the gateway answers", async () => {
|
|
266
|
+
// A request that never lands says nothing about what the edge serves, so
|
|
267
|
+
// the answered `/healthz` stands.
|
|
268
|
+
const { fetchImpl } = stubFetch({
|
|
269
|
+
config: { reject: new TypeError("fetch failed") },
|
|
270
|
+
});
|
|
271
|
+
await expect(
|
|
272
|
+
probeTunnel({
|
|
273
|
+
publicBaseUrl: BASE,
|
|
274
|
+
expectedAssistantId: "asst_1",
|
|
275
|
+
fetchImpl,
|
|
276
|
+
}),
|
|
277
|
+
).resolves.toEqual({
|
|
278
|
+
kind: "healthy",
|
|
279
|
+
assistantId: undefined,
|
|
280
|
+
assistantName: undefined,
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("unreachable when the request rejects", async () => {
|
|
285
|
+
const fetchImpl = asFetch(async () => {
|
|
286
|
+
throw new TypeError("Unable to connect");
|
|
287
|
+
});
|
|
288
|
+
await expect(
|
|
289
|
+
probeTunnel({ publicBaseUrl: BASE, fetchImpl }),
|
|
290
|
+
).resolves.toEqual({ kind: "unreachable", detail: "Unable to connect" });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("unreachable when nginx answers but the gateway is down", async () => {
|
|
294
|
+
const { fetchImpl } = stubFetch({
|
|
295
|
+
healthz: { status: 502, body: "bad gateway" },
|
|
296
|
+
config: configBody({ assistantId: "asst_1" }),
|
|
297
|
+
});
|
|
298
|
+
await expect(
|
|
299
|
+
probeTunnel({
|
|
300
|
+
publicBaseUrl: BASE,
|
|
301
|
+
expectedAssistantId: "asst_1",
|
|
302
|
+
fetchImpl,
|
|
303
|
+
}),
|
|
304
|
+
).resolves.toEqual({ kind: "unreachable", detail: "HTTP 502" });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("unreachable when the probe times out", async () => {
|
|
308
|
+
const fetchImpl = asFetch(
|
|
309
|
+
(_input, init) =>
|
|
310
|
+
new Promise<Response>((_resolve, reject) => {
|
|
311
|
+
init?.signal?.addEventListener("abort", () => {
|
|
312
|
+
reject(init.signal?.reason);
|
|
313
|
+
});
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
await expect(
|
|
317
|
+
probeTunnel({ publicBaseUrl: BASE, timeoutMs: 5, fetchImpl }),
|
|
318
|
+
).resolves.toEqual({ kind: "unreachable", detail: "timeout" });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("healthy when a slow edge answers /healthz but not the config", async () => {
|
|
322
|
+
// Both requests share one deadline, so an edge that answers `/healthz`
|
|
323
|
+
// just inside it loses the identity request to it. That is still a
|
|
324
|
+
// working tunnel.
|
|
325
|
+
const timeout = new DOMException("The operation timed out", "TimeoutError");
|
|
326
|
+
const { fetchImpl } = stubFetch({ config: { reject: timeout } });
|
|
327
|
+
await expect(
|
|
328
|
+
probeTunnel({
|
|
329
|
+
publicBaseUrl: BASE,
|
|
330
|
+
expectedAssistantId: "asst_1",
|
|
331
|
+
fetchImpl,
|
|
332
|
+
}),
|
|
333
|
+
).resolves.toEqual({
|
|
334
|
+
kind: "healthy",
|
|
335
|
+
assistantId: undefined,
|
|
336
|
+
assistantName: undefined,
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("surfaces the syscall code hidden under a generic fetch wrapper", async () => {
|
|
341
|
+
const cause = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:80"), {
|
|
342
|
+
code: "ECONNREFUSED",
|
|
343
|
+
});
|
|
344
|
+
const fetchImpl = asFetch(async () => {
|
|
345
|
+
throw new TypeError("fetch failed", { cause });
|
|
346
|
+
});
|
|
347
|
+
await expect(
|
|
348
|
+
probeTunnel({ publicBaseUrl: BASE, fetchImpl }),
|
|
349
|
+
).resolves.toEqual({ kind: "unreachable", detail: "ECONNREFUSED" });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("falls back to the cause's message when neither level has a code", async () => {
|
|
353
|
+
const fetchImpl = asFetch(async () => {
|
|
354
|
+
throw new TypeError("fetch failed", {
|
|
355
|
+
cause: new Error("getaddrinfo lookup failed"),
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
await expect(
|
|
359
|
+
probeTunnel({ publicBaseUrl: BASE, fetchImpl }),
|
|
360
|
+
).resolves.toEqual({
|
|
361
|
+
kind: "unreachable",
|
|
362
|
+
detail: "getaddrinfo lookup failed",
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("keeps the probed URL out of the failure detail", async () => {
|
|
367
|
+
const fetchImpl = asFetch(async (input) => {
|
|
368
|
+
throw new TypeError(`Failed to parse URL from ${String(input)}`);
|
|
369
|
+
});
|
|
370
|
+
const result = await probeTunnel({ publicBaseUrl: BASE, fetchImpl });
|
|
371
|
+
expect(result.kind).toEqual("unreachable");
|
|
372
|
+
expect(result).toHaveProperty("detail");
|
|
373
|
+
expect((result as { detail: string }).detail).not.toContain(BASE);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("unreachable when there is no URL to probe", async () => {
|
|
377
|
+
const { fetchImpl, urls } = stubFetch({});
|
|
378
|
+
const result = await probeTunnel({ publicBaseUrl: " ", fetchImpl });
|
|
379
|
+
expect(result).toEqual({
|
|
380
|
+
kind: "unreachable",
|
|
381
|
+
detail: "no public base URL",
|
|
382
|
+
});
|
|
383
|
+
expect(urls).toEqual([]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test.each(["not-a-url", "one.ngrok.app", "ftp://x", "https://x?a=b"])(
|
|
387
|
+
"unreachable without a request for the malformed URL %p",
|
|
388
|
+
async (publicBaseUrl) => {
|
|
389
|
+
const { fetchImpl, urls } = stubFetch({});
|
|
390
|
+
const result = await probeTunnel({ publicBaseUrl, fetchImpl });
|
|
391
|
+
expect(result).toEqual({
|
|
392
|
+
kind: "unreachable",
|
|
393
|
+
detail: "not an http(s) URL",
|
|
394
|
+
});
|
|
395
|
+
expect(urls).toEqual([]);
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
test("cancels the health body it never reads", async () => {
|
|
400
|
+
const { fetchImpl, cancelled } = stubFetch({
|
|
401
|
+
config: configBody({ assistantId: "asst_1" }),
|
|
402
|
+
});
|
|
403
|
+
await probeTunnel({ publicBaseUrl: BASE, fetchImpl });
|
|
404
|
+
expect(cancelled).toEqual(["healthz"]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("cancels both bodies when the gateway is down", async () => {
|
|
408
|
+
const { fetchImpl, cancelled } = stubFetch({
|
|
409
|
+
healthz: { status: 502, body: "bad gateway" },
|
|
410
|
+
config: configBody({ assistantId: "asst_1" }),
|
|
411
|
+
});
|
|
412
|
+
await probeTunnel({ publicBaseUrl: BASE, fetchImpl });
|
|
413
|
+
expect(cancelled.sort()).toEqual(["config", "healthz"]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("cancels the config body it does not parse", async () => {
|
|
417
|
+
const { fetchImpl, cancelled } = stubFetch({
|
|
418
|
+
config: { status: 404, body: "not found" },
|
|
419
|
+
});
|
|
420
|
+
await probeTunnel({ publicBaseUrl: BASE, fetchImpl });
|
|
421
|
+
expect(cancelled.sort()).toEqual(["config", "healthz"]);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("survives a body that refuses to be cancelled", async () => {
|
|
425
|
+
const fetchImpl = asFetch(async () =>
|
|
426
|
+
Promise.resolve({
|
|
427
|
+
ok: true,
|
|
428
|
+
status: 200,
|
|
429
|
+
body: {
|
|
430
|
+
cancel: async (): Promise<void> => {
|
|
431
|
+
throw new Error("already locked");
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
json: async (): Promise<unknown> => ({
|
|
435
|
+
mode: PAIRING_EDGE_MODE,
|
|
436
|
+
assistantId: "asst_1",
|
|
437
|
+
}),
|
|
438
|
+
} as unknown as Response),
|
|
439
|
+
);
|
|
440
|
+
await expect(
|
|
441
|
+
probeTunnel({ publicBaseUrl: BASE, fetchImpl }),
|
|
442
|
+
).resolves.toEqual({
|
|
443
|
+
kind: "healthy",
|
|
444
|
+
assistantId: "asst_1",
|
|
445
|
+
assistantName: undefined,
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -13,15 +13,21 @@
|
|
|
13
13
|
* POST {VELLUM_PLATFORM_URL}/v1/internal/gateway/callback-routes/register/
|
|
14
14
|
*
|
|
15
15
|
* It accepts { assistant_id, callback_path, type } and returns a stable
|
|
16
|
-
* callback_url that external services should use.
|
|
16
|
+
* callback_url that external services should use. Self-hosted assistants
|
|
17
|
+
* also send callback_base_url (configured or detected public ingress) so
|
|
18
|
+
* the platform can register a route that points at this instance.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { getPlatformAssistantId, getPlatformBaseUrl } from "../config/env.js";
|
|
20
22
|
import { getIsPlatform } from "../config/env-registry.js";
|
|
23
|
+
import { getConfig } from "../config/loader.js";
|
|
21
24
|
import { credentialKey } from "../security/credential-key.js";
|
|
22
25
|
import { getSecureKeyAsync } from "../security/secure-keys.js";
|
|
23
26
|
import { getLogger } from "../util/logger.js";
|
|
24
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
PublicIngressDisabledError,
|
|
29
|
+
tryGetPublicBaseUrl,
|
|
30
|
+
} from "./public-ingress-urls.js";
|
|
25
31
|
|
|
26
32
|
const log = getLogger("platform-callback-registration");
|
|
27
33
|
|
|
@@ -123,6 +129,10 @@ export async function registerCallbackRoute(
|
|
|
123
129
|
if (sourceIdentifier) {
|
|
124
130
|
payload.source_identifier = sourceIdentifier;
|
|
125
131
|
}
|
|
132
|
+
const callbackBaseUrl = resolveSelfHostedCallbackBaseUrl();
|
|
133
|
+
if (callbackBaseUrl) {
|
|
134
|
+
payload.callback_base_url = callbackBaseUrl;
|
|
135
|
+
}
|
|
126
136
|
const body = JSON.stringify(payload);
|
|
127
137
|
|
|
128
138
|
log.debug({ callbackPath, type }, "Registering platform callback route");
|
|
@@ -151,6 +161,22 @@ export async function registerCallbackRoute(
|
|
|
151
161
|
return data.callback_url;
|
|
152
162
|
}
|
|
153
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Self-hosted assistants must send the public ingress URL when registering
|
|
166
|
+
* with the platform. Platform pods omit it: Django ignores client-provided
|
|
167
|
+
* bases and always uses the platform callback URL.
|
|
168
|
+
*/
|
|
169
|
+
function resolveSelfHostedCallbackBaseUrl(): string | undefined {
|
|
170
|
+
if (getIsPlatform()) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
return tryGetPublicBaseUrl(getConfig());
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
154
180
|
/**
|
|
155
181
|
* Resolve a callback URL, registering with the platform when appropriate.
|
|
156
182
|
*
|
|
@@ -111,6 +111,18 @@ export function getPublicBaseUrl(config: IngressConfig): string {
|
|
|
111
111
|
);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the public ingress base URL, or `undefined` when none is configured
|
|
116
|
+
* or ingress is explicitly disabled.
|
|
117
|
+
*/
|
|
118
|
+
export function tryGetPublicBaseUrl(config: IngressConfig): string | undefined {
|
|
119
|
+
try {
|
|
120
|
+
return getPublicBaseUrl(config);
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
/**
|
|
115
127
|
* Build the Twilio voice webhook URL.
|
|
116
128
|
*
|