@vellumai/assistant 0.5.4 → 0.5.5
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/Dockerfile +18 -27
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- package/package.json +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
- package/src/config/env-registry.ts +9 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/credential-execution/managed-catalog.ts +5 -15
- package/src/daemon/config-watcher.ts +4 -1
- package/src/daemon/daemon-control.ts +7 -0
- package/src/daemon/lifecycle.ts +7 -1
- package/src/daemon/providers-setup.ts +2 -1
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- package/src/memory/embedding-local.ts +11 -5
- package/src/memory/job-handlers/conversation-starters.ts +24 -36
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/slack/adapter.ts +29 -2
- package/src/oauth/connection-resolver.test.ts +22 -18
- package/src/oauth/connection-resolver.ts +92 -7
- package/src/oauth/platform-connection.test.ts +78 -69
- package/src/oauth/platform-connection.ts +12 -19
- package/src/permissions/trust-client.ts +343 -0
- package/src/permissions/trust-store-interface.ts +105 -0
- package/src/permissions/trust-store.ts +523 -36
- package/src/platform/client.test.ts +148 -0
- package/src/platform/client.ts +71 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
- package/src/providers/speech-to-text/openai-whisper.ts +68 -0
- package/src/providers/speech-to-text/resolve.ts +9 -0
- package/src/providers/speech-to-text/types.ts +17 -0
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +27 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
- package/src/runtime/routes/log-export-routes.ts +1 -0
- package/src/runtime/routes/secret-routes.ts +4 -1
- package/src/security/ces-credential-client.ts +173 -0
- package/src/security/secure-keys.ts +65 -22
- package/src/signals/bash.ts +3 -0
- package/src/signals/cancel.ts +3 -0
- package/src/signals/confirm.ts +3 -0
- package/src/signals/conversation-undo.ts +3 -0
- package/src/signals/event-stream.ts +7 -0
- package/src/signals/shotgun.ts +3 -0
- package/src/signals/trust-rule.ts +3 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
- package/src/telemetry/usage-telemetry-reporter.ts +21 -19
- package/src/util/device-id.ts +70 -7
- package/src/util/logger.ts +35 -9
- package/src/util/platform.ts +29 -5
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -176,11 +176,11 @@ async function generateStarters(scopeId: string): Promise<GeneratedStarter[]> {
|
|
|
176
176
|
? truncate(rawIdentityContext, 2000, "\n…[truncated]")
|
|
177
177
|
: null;
|
|
178
178
|
|
|
179
|
-
const systemPrompt = `You are generating 4 conversation starters for a personal assistant app. These appear as clickable chips on the empty conversation page — the first thing the user sees when they open the app.
|
|
179
|
+
const systemPrompt = `You are generating 4 conversation starters for a personal assistant app. These appear as clickable chips on the empty conversation page — the first thing the user sees when they open the app. Clicking a chip sends its prompt as a message from the user.
|
|
180
180
|
|
|
181
181
|
${timeContext}
|
|
182
182
|
|
|
183
|
-
Your goal:
|
|
183
|
+
Your goal: suggest the 4 most useful things this person could ask you to do right now.
|
|
184
184
|
|
|
185
185
|
${identityContext ? `## Assistant identity & user profile\n\n${identityContext}\n\n` : ""}## What you know
|
|
186
186
|
|
|
@@ -188,7 +188,9 @@ ${rollup}
|
|
|
188
188
|
${diff}
|
|
189
189
|
${skills}
|
|
190
190
|
|
|
191
|
-
##
|
|
191
|
+
## Selection
|
|
192
|
+
|
|
193
|
+
Generate exactly 4 starters, ranked #1 (best) to #4.
|
|
192
194
|
|
|
193
195
|
Start from the user's situation, not from the skill list. Ask yourself:
|
|
194
196
|
- What is this person likely dealing with right now (given the day/time and their context)?
|
|
@@ -197,11 +199,7 @@ Start from the user's situation, not from the skill list. Ask yourself:
|
|
|
197
199
|
|
|
198
200
|
The skills list tells you what the assistant CAN do — use it to filter out suggestions the assistant can't actually help with, not as a menu to generate suggestions from.
|
|
199
201
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
Generate exactly 4 starters, ranked #1 (best) to #4.
|
|
203
|
-
|
|
204
|
-
For each, you must be able to clearly answer:
|
|
202
|
+
For each starter, you must clearly answer:
|
|
205
203
|
- Why now? (timing — day of week, recent activity, upcoming deadline)
|
|
206
204
|
- Why this user? (grounded in their specific context, not generic)
|
|
207
205
|
- Why would they be glad I suggested this? (genuine usefulness, not just relevance)
|
|
@@ -218,44 +216,34 @@ Favor what is live over what is merely true. Recent changes matter more than old
|
|
|
218
216
|
|
|
219
217
|
## Output format
|
|
220
218
|
|
|
221
|
-
Return exactly 4 starters in rank order (best first).
|
|
222
|
-
|
|
223
219
|
Each starter has:
|
|
224
|
-
- label: 3-6 words, max 40 chars, starts with a verb.
|
|
225
|
-
- prompt: 1-2 natural sentences,
|
|
220
|
+
- label: 3-6 words, max 40 chars, starts with a verb. Written in the user's voice — something they'd want to do, not something the assistant is offering.
|
|
221
|
+
- prompt: 1-2 natural sentences, as the user would actually say them.
|
|
226
222
|
- category: one of ${CONVERSATION_STARTER_CATEGORIES.join(", ")}
|
|
227
223
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
Never include a chip whose primary meaning is configuration, setup, workflow creation, or "set up X for Y" unless it solves an urgent pain the user is actively feeling right now. Prefer the outcome over the mechanism — "Catch the emails that matter" beats "Set up a playbook for inbox."
|
|
231
|
-
|
|
232
|
-
## Topic diversity
|
|
233
|
-
|
|
234
|
-
Each chip should cover a distinct topic or concern. Never have two chips about the same tool, project, or theme — even if there are multiple related issues. Pick the single most impactful angle and give the other slot to something different. Four chips about three topics is too narrow; four chips about four topics is right.
|
|
224
|
+
## Constraints
|
|
235
225
|
|
|
236
|
-
|
|
226
|
+
**Voice**: The user clicks these chips to send a message. Every label must read as something the user is asking to do, never something the assistant is saying to the user.
|
|
237
227
|
|
|
238
|
-
|
|
228
|
+
**Coherence**: The 4 starters should feel like one set — similar abstraction level, no jarring mix of mundane chores and life strategy.
|
|
239
229
|
|
|
240
|
-
|
|
230
|
+
**Diversity**: Each chip covers a distinct topic. Never two chips about the same tool, project, or theme. Four topics, four chips.
|
|
241
231
|
|
|
242
|
-
|
|
232
|
+
**No setup chips**: Never include a chip whose primary meaning is configuration or "set up X for Y" unless it solves an urgent pain the user is actively feeling. Prefer the outcome over the mechanism.
|
|
243
233
|
|
|
244
|
-
|
|
234
|
+
**Natural language**: No jargon, project names, or raw memory phrases in labels unless they already sound natural in conversation. If a label sounds like a ticket title or backlog item, rewrite it as something the user would actually say.
|
|
245
235
|
|
|
246
|
-
Examples
|
|
247
|
-
- BAD: "Fix Slack Socket Mode blocker" → GOOD: "Fix Slack so it just works"
|
|
248
|
-
- BAD: "Rewire messaging for Socket Mode" → GOOD: "Get Socket Mode stable"
|
|
249
|
-
- BAD: "Review this week's calendar" → GOOD: "Protect this week's focus"
|
|
250
|
-
- BAD: "Model the coaching transition" → GOOD: "Plan the coaching transition"
|
|
251
|
-
- BAD: "Restore outgoing Slack messages" → GOOD: "Get Slack messages flowing"
|
|
252
|
-
- BAD: "Set up a playbook for inbox" → GOOD: "Catch the emails that matter"
|
|
236
|
+
## Examples
|
|
253
237
|
|
|
254
|
-
|
|
255
|
-
-
|
|
256
|
-
-
|
|
238
|
+
Bad → Good (ticket-speak → natural):
|
|
239
|
+
- "Fix Slack Socket Mode blocker" → "Fix Slack so it just works"
|
|
240
|
+
- "Restore outgoing Slack messages" → "Get Slack messages flowing"
|
|
241
|
+
- "Review this week's calendar" → "Protect this week's focus"
|
|
242
|
+
- "Set up a playbook for inbox" → "Triage my inbox"
|
|
257
243
|
|
|
258
|
-
|
|
244
|
+
Bad → Good (assistant voice → user voice):
|
|
245
|
+
- "You've got a busy week ahead" → "Plan my week ahead"
|
|
246
|
+
- "Let me check your calendar" → "Check my Thursday schedule"`;
|
|
259
247
|
|
|
260
248
|
const { signal, cleanup } = createTimeout(20000);
|
|
261
249
|
try {
|
|
@@ -280,7 +268,7 @@ The good versions emphasize the user's payoff in the user's own voice, not the i
|
|
|
280
268
|
label: {
|
|
281
269
|
type: "string",
|
|
282
270
|
description:
|
|
283
|
-
"User-voice chip
|
|
271
|
+
"User-voice chip label (2-7 words, max 40 chars, verb-first)",
|
|
284
272
|
},
|
|
285
273
|
prompt: {
|
|
286
274
|
type: "string",
|
|
@@ -89,6 +89,15 @@ export interface MessagingProvider {
|
|
|
89
89
|
*/
|
|
90
90
|
isConnected?(): Promise<boolean>;
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Custom credential resolution for providers with non-standard credential
|
|
94
|
+
* paths (e.g. Slack Socket Mode stores tokens under "slack_channel" rather
|
|
95
|
+
* than the OAuth provider key). When present, getProviderConnection() calls
|
|
96
|
+
* this instead of resolveOAuthConnection(), giving the provider full control
|
|
97
|
+
* over credential lookup including fallback strategies.
|
|
98
|
+
*/
|
|
99
|
+
resolveConnection?(account?: string): Promise<OAuthConnection | string>;
|
|
100
|
+
|
|
92
101
|
/** Platform-specific capabilities for tool routing (e.g. 'reactions', 'threads', 'labels'). */
|
|
93
102
|
capabilities: Set<string>;
|
|
94
103
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slack messaging provider adapter.
|
|
3
3
|
*
|
|
4
|
-
* Maps Slack API responses to the platform-agnostic messaging types
|
|
5
|
-
*
|
|
4
|
+
* Maps Slack API responses to the platform-agnostic messaging types and
|
|
5
|
+
* implements the MessagingProvider interface.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { OAuthConnection } from "../../../oauth/connection.js";
|
|
9
|
+
import { resolveOAuthConnection } from "../../../oauth/connection-resolver.js";
|
|
10
|
+
import { isProviderConnected } from "../../../oauth/oauth-store.js";
|
|
11
|
+
import { credentialKey } from "../../../security/credential-key.js";
|
|
12
|
+
import { getSecureKeyAsync } from "../../../security/secure-keys.js";
|
|
9
13
|
import type { MessagingProvider } from "../../provider.js";
|
|
10
14
|
import type {
|
|
11
15
|
ConnectionInfo,
|
|
@@ -112,6 +116,29 @@ export const slackProvider: MessagingProvider = {
|
|
|
112
116
|
credentialService: "integration:slack",
|
|
113
117
|
capabilities: new Set(["reactions", "threads", "leave_channel"]),
|
|
114
118
|
|
|
119
|
+
async isConnected(): Promise<boolean> {
|
|
120
|
+
// Socket Mode: check for bot token directly in credential store.
|
|
121
|
+
// The token is the source of truth; the slack_channel connection row
|
|
122
|
+
// is advisory (backfill can fail non-fatally on startup).
|
|
123
|
+
const botToken = await getSecureKeyAsync(
|
|
124
|
+
credentialKey("slack_channel", "bot_token"),
|
|
125
|
+
);
|
|
126
|
+
if (botToken) return true;
|
|
127
|
+
// Preserve existing OAuth path (integration:slack) for backwards compat.
|
|
128
|
+
return isProviderConnected("integration:slack");
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async resolveConnection(account?: string): Promise<OAuthConnection | string> {
|
|
132
|
+
// Socket Mode: return raw bot token if available.
|
|
133
|
+
// Token presence is sufficient — no connection row required.
|
|
134
|
+
const botToken = await getSecureKeyAsync(
|
|
135
|
+
credentialKey("slack_channel", "bot_token"),
|
|
136
|
+
);
|
|
137
|
+
if (botToken) return botToken;
|
|
138
|
+
// Preserve existing OAuth path (integration:slack) for backwards compat.
|
|
139
|
+
return resolveOAuthConnection("integration:slack", { account });
|
|
140
|
+
},
|
|
141
|
+
|
|
115
142
|
async testConnection(
|
|
116
143
|
connectionOrToken: OAuthConnection | string,
|
|
117
144
|
): Promise<ConnectionInfo> {
|
|
@@ -8,12 +8,7 @@ let mockProvider: Record<string, unknown> | undefined;
|
|
|
8
8
|
let mockConnection: Record<string, unknown> | undefined;
|
|
9
9
|
let mockAccessToken: string | undefined;
|
|
10
10
|
let mockConfig: Record<string, unknown> = {};
|
|
11
|
-
let
|
|
12
|
-
enabled: false,
|
|
13
|
-
platformBaseUrl: "",
|
|
14
|
-
assistantApiKey: "",
|
|
15
|
-
};
|
|
16
|
-
let mockAssistantId = "";
|
|
11
|
+
let mockPlatformClient: Record<string, unknown> | null = null;
|
|
17
12
|
|
|
18
13
|
// ---------------------------------------------------------------------------
|
|
19
14
|
// Module mocks (must precede imports of the module under test)
|
|
@@ -48,12 +43,10 @@ mock.module("../config/loader.js", () => ({
|
|
|
48
43
|
getConfig: () => mockConfig,
|
|
49
44
|
}));
|
|
50
45
|
|
|
51
|
-
mock.module("../
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
mock.module("../providers/managed-proxy/context.js", () => ({
|
|
56
|
-
resolveManagedProxyContext: async () => mockManagedProxyCtx,
|
|
46
|
+
mock.module("../platform/client.js", () => ({
|
|
47
|
+
VellumPlatformClient: {
|
|
48
|
+
create: async () => mockPlatformClient,
|
|
49
|
+
},
|
|
57
50
|
}));
|
|
58
51
|
|
|
59
52
|
// ---------------------------------------------------------------------------
|
|
@@ -68,6 +61,22 @@ import { PlatformOAuthConnection } from "./platform-connection.js";
|
|
|
68
61
|
// Helpers
|
|
69
62
|
// ---------------------------------------------------------------------------
|
|
70
63
|
|
|
64
|
+
function makeMockClient() {
|
|
65
|
+
return {
|
|
66
|
+
baseUrl: "https://platform.example.com",
|
|
67
|
+
assistantApiKey: "sk-test-key",
|
|
68
|
+
platformAssistantId: "asst-123",
|
|
69
|
+
fetch: mock(async () => {
|
|
70
|
+
return new Response(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
results: [{ id: "platform-conn-1", account_label: null }],
|
|
73
|
+
}),
|
|
74
|
+
{ status: 200 },
|
|
75
|
+
);
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
function setupDefaults(): void {
|
|
72
81
|
mockProvider = {
|
|
73
82
|
providerKey: "integration:google",
|
|
@@ -100,12 +109,7 @@ function setupDefaults(): void {
|
|
|
100
109
|
"google-oauth": { mode: "managed" },
|
|
101
110
|
},
|
|
102
111
|
};
|
|
103
|
-
|
|
104
|
-
enabled: true,
|
|
105
|
-
platformBaseUrl: "https://platform.example.com",
|
|
106
|
-
assistantApiKey: "sk-test-key",
|
|
107
|
-
};
|
|
108
|
-
mockAssistantId = "asst-123";
|
|
112
|
+
mockPlatformClient = makeMockClient();
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
// ---------------------------------------------------------------------------
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { getPlatformAssistantId } from "../config/env.js";
|
|
2
1
|
import { getConfig } from "../config/loader.js";
|
|
3
2
|
import { type Services, ServicesSchema } from "../config/schemas/services.js";
|
|
4
|
-
import {
|
|
3
|
+
import { VellumPlatformClient } from "../platform/client.js";
|
|
5
4
|
import { getSecureKeyAsync } from "../security/secure-keys.js";
|
|
5
|
+
import { getLogger } from "../util/logger.js";
|
|
6
6
|
import { BYOOAuthConnection } from "./byo-connection.js";
|
|
7
7
|
import type { OAuthConnection } from "./connection.js";
|
|
8
8
|
import { getActiveConnection, getProvider } from "./oauth-store.js";
|
|
9
9
|
import { PlatformOAuthConnection } from "./platform-connection.js";
|
|
10
10
|
|
|
11
|
+
const log = getLogger("connection-resolver");
|
|
12
|
+
|
|
11
13
|
export interface ResolveOAuthConnectionOptions {
|
|
12
14
|
/** OAuth app client ID — narrows to a specific app when multiple BYO apps
|
|
13
15
|
* exist for the same provider. */
|
|
@@ -46,16 +48,32 @@ export async function resolveOAuthConnection(
|
|
|
46
48
|
if (managedKey && managedKey in ServicesSchema.shape) {
|
|
47
49
|
const services: Services = getConfig().services;
|
|
48
50
|
if (services[managedKey as keyof Services].mode === "managed") {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
+
const client = await VellumPlatformClient.create();
|
|
52
|
+
if (!client || !client.platformAssistantId) {
|
|
53
|
+
const detail = !client
|
|
54
|
+
? "missing platform prerequisites"
|
|
55
|
+
: "missing assistant ID";
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Platform-managed connection for "${providerKey}" cannot be created: ${detail}. ` +
|
|
58
|
+
`Log in to the Vellum platform or switch to using your own OAuth app.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const providerSlug = providerKey.replace(/^integration:/, "");
|
|
63
|
+
|
|
64
|
+
const connectionId = await resolvePlatformConnectionId({
|
|
65
|
+
client,
|
|
66
|
+
provider: providerSlug,
|
|
67
|
+
account,
|
|
68
|
+
});
|
|
69
|
+
|
|
51
70
|
return new PlatformOAuthConnection({
|
|
52
71
|
id: providerKey,
|
|
53
72
|
providerKey,
|
|
54
73
|
externalId: providerKey,
|
|
55
74
|
accountInfo: account ?? null,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
apiKey: ctx.assistantApiKey,
|
|
75
|
+
client,
|
|
76
|
+
connectionId,
|
|
59
77
|
});
|
|
60
78
|
}
|
|
61
79
|
}
|
|
@@ -98,3 +116,70 @@ export async function resolveOAuthConnection(
|
|
|
98
116
|
accountInfo: conn.accountInfo,
|
|
99
117
|
});
|
|
100
118
|
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Platform connection ID resolution
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
interface ResolvePlatformConnectionIdOptions {
|
|
125
|
+
client: VellumPlatformClient;
|
|
126
|
+
provider: string;
|
|
127
|
+
account?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fetch the platform-side connection ID for a managed provider by calling
|
|
132
|
+
* the List Connections endpoint.
|
|
133
|
+
*/
|
|
134
|
+
async function resolvePlatformConnectionId(
|
|
135
|
+
options: ResolvePlatformConnectionIdOptions,
|
|
136
|
+
): Promise<string> {
|
|
137
|
+
const { client, provider, account } = options;
|
|
138
|
+
|
|
139
|
+
const params = new URLSearchParams();
|
|
140
|
+
params.set("provider", provider);
|
|
141
|
+
params.set("status", "ACTIVE");
|
|
142
|
+
if (account) {
|
|
143
|
+
params.set("account_identifier", account);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const path = `/v1/assistants/${client.platformAssistantId}/oauth/connections/?${params.toString()}`;
|
|
147
|
+
const response = await client.fetch(path);
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
log.error(
|
|
151
|
+
{ status: response.status, provider },
|
|
152
|
+
"Failed to list platform OAuth connections",
|
|
153
|
+
);
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Failed to resolve platform connection for "${provider}": HTTP ${response.status}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const body = (await response.json()) as {
|
|
160
|
+
results?: Array<{ id: string; account_label?: string }>;
|
|
161
|
+
};
|
|
162
|
+
const connections = body.results ?? [];
|
|
163
|
+
|
|
164
|
+
if (connections.length === 0) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`No active platform OAuth connection found for provider "${provider}"` +
|
|
167
|
+
(account ? ` with account "${account}"` : "") +
|
|
168
|
+
". Connect the service on the Vellum platform first.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (connections.length > 1 && !account) {
|
|
173
|
+
log.warn(
|
|
174
|
+
{
|
|
175
|
+
provider,
|
|
176
|
+
count: connections.length,
|
|
177
|
+
selectedId: connections[0].id,
|
|
178
|
+
},
|
|
179
|
+
"Multiple active platform connections found; using the most recently created. " +
|
|
180
|
+
"Pass an account option to select a specific connection.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return connections[0].id;
|
|
185
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import type { VellumPlatformClient } from "../platform/client.js";
|
|
3
4
|
import { BackendError, VellumError } from "../util/errors.js";
|
|
4
5
|
import {
|
|
5
6
|
CredentialRequiredError,
|
|
@@ -7,40 +8,53 @@ import {
|
|
|
7
8
|
ProviderUnreachableError,
|
|
8
9
|
} from "./platform-connection.js";
|
|
9
10
|
|
|
11
|
+
function makeMockClient(
|
|
12
|
+
fetchImpl?: typeof globalThis.fetch,
|
|
13
|
+
): VellumPlatformClient {
|
|
14
|
+
const mockFetchFn =
|
|
15
|
+
fetchImpl ??
|
|
16
|
+
(mock(async () => {
|
|
17
|
+
return new Response(
|
|
18
|
+
JSON.stringify({ status: 200, headers: {}, body: null }),
|
|
19
|
+
{ status: 200 },
|
|
20
|
+
);
|
|
21
|
+
}) as unknown as typeof globalThis.fetch);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
baseUrl: "https://platform.example.com",
|
|
25
|
+
assistantApiKey: "test-api-key",
|
|
26
|
+
platformAssistantId: "asst-abc",
|
|
27
|
+
fetch: mock(async (path: string, init?: RequestInit) => {
|
|
28
|
+
const url = `https://platform.example.com${path}`;
|
|
29
|
+
const headers = new Headers(init?.headers);
|
|
30
|
+
headers.set("Authorization", "Api-Key test-api-key");
|
|
31
|
+
return mockFetchFn(url, { ...init, headers });
|
|
32
|
+
}),
|
|
33
|
+
} as unknown as VellumPlatformClient;
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
const DEFAULT_OPTIONS = {
|
|
11
37
|
id: "conn-1",
|
|
12
38
|
providerKey: "integration:google",
|
|
13
39
|
externalId: "ext-123",
|
|
14
40
|
accountInfo: "user@example.com",
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
apiKey: "test-api-key",
|
|
41
|
+
client: makeMockClient(),
|
|
42
|
+
connectionId: "platform-conn-123",
|
|
18
43
|
};
|
|
19
44
|
|
|
20
45
|
describe("PlatformOAuthConnection", () => {
|
|
21
|
-
let originalFetch: typeof globalThis.fetch;
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
originalFetch = globalThis.fetch;
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
globalThis.fetch = originalFetch;
|
|
29
|
-
});
|
|
30
|
-
|
|
31
46
|
test("successful proxied request", async () => {
|
|
32
47
|
const upstreamBody = { messages: [{ id: "msg-1", snippet: "Hello" }] };
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
async (url: string | URL | Request, init?: RequestInit) => {
|
|
36
|
-
expect(url).toBe(
|
|
37
|
-
"https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/
|
|
49
|
+
const client = makeMockClient(
|
|
50
|
+
mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
51
|
+
expect(String(url)).toBe(
|
|
52
|
+
"https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/platform-conn-123/",
|
|
38
53
|
);
|
|
39
54
|
expect(init?.method).toBe("POST");
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
55
|
+
const headers = new Headers(init?.headers);
|
|
56
|
+
expect(headers.get("Authorization")).toBe("Api-Key test-api-key");
|
|
57
|
+
expect(headers.get("Content-Type")).toBe("application/json");
|
|
44
58
|
|
|
45
59
|
const parsed = JSON.parse(init?.body as string);
|
|
46
60
|
expect(parsed).toEqual({
|
|
@@ -61,10 +75,13 @@ describe("PlatformOAuthConnection", () => {
|
|
|
61
75
|
}),
|
|
62
76
|
{ status: 200 },
|
|
63
77
|
);
|
|
64
|
-
},
|
|
65
|
-
)
|
|
78
|
+
}) as unknown as typeof globalThis.fetch,
|
|
79
|
+
);
|
|
66
80
|
|
|
67
|
-
const conn = new PlatformOAuthConnection(
|
|
81
|
+
const conn = new PlatformOAuthConnection({
|
|
82
|
+
...DEFAULT_OPTIONS,
|
|
83
|
+
client,
|
|
84
|
+
});
|
|
68
85
|
const result = await conn.request({
|
|
69
86
|
method: "GET",
|
|
70
87
|
path: "/gmail/v1/users/me/messages",
|
|
@@ -77,8 +94,8 @@ describe("PlatformOAuthConnection", () => {
|
|
|
77
94
|
});
|
|
78
95
|
|
|
79
96
|
test("forwards baseUrl when provided", async () => {
|
|
80
|
-
|
|
81
|
-
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
97
|
+
const client = makeMockClient(
|
|
98
|
+
mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
82
99
|
const parsed = JSON.parse(init?.body as string);
|
|
83
100
|
expect(parsed.request.baseUrl).toBe(
|
|
84
101
|
"https://www.googleapis.com/calendar/v3",
|
|
@@ -88,10 +105,10 @@ describe("PlatformOAuthConnection", () => {
|
|
|
88
105
|
JSON.stringify({ status: 200, headers: {}, body: {} }),
|
|
89
106
|
{ status: 200 },
|
|
90
107
|
);
|
|
91
|
-
},
|
|
92
|
-
)
|
|
108
|
+
}) as unknown as typeof globalThis.fetch,
|
|
109
|
+
);
|
|
93
110
|
|
|
94
|
-
const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
|
|
111
|
+
const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
|
|
95
112
|
await conn.request({
|
|
96
113
|
method: "GET",
|
|
97
114
|
path: "/calendars/primary/events",
|
|
@@ -100,8 +117,8 @@ describe("PlatformOAuthConnection", () => {
|
|
|
100
117
|
});
|
|
101
118
|
|
|
102
119
|
test("omits baseUrl from envelope when not provided", async () => {
|
|
103
|
-
|
|
104
|
-
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
120
|
+
const client = makeMockClient(
|
|
121
|
+
mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
105
122
|
const parsed = JSON.parse(init?.body as string);
|
|
106
123
|
expect("baseUrl" in parsed.request).toBe(false);
|
|
107
124
|
|
|
@@ -109,10 +126,10 @@ describe("PlatformOAuthConnection", () => {
|
|
|
109
126
|
JSON.stringify({ status: 200, headers: {}, body: null }),
|
|
110
127
|
{ status: 200 },
|
|
111
128
|
);
|
|
112
|
-
},
|
|
113
|
-
)
|
|
129
|
+
}) as unknown as typeof globalThis.fetch,
|
|
130
|
+
);
|
|
114
131
|
|
|
115
|
-
const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
|
|
132
|
+
const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
|
|
116
133
|
await conn.request({ method: "GET", path: "/some/path" });
|
|
117
134
|
});
|
|
118
135
|
|
|
@@ -127,22 +144,26 @@ describe("PlatformOAuthConnection", () => {
|
|
|
127
144
|
});
|
|
128
145
|
|
|
129
146
|
test("424 response throws CredentialRequiredError", async () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
147
|
+
const client = makeMockClient(
|
|
148
|
+
mock(
|
|
149
|
+
async () => new Response("", { status: 424 }),
|
|
150
|
+
) as unknown as typeof globalThis.fetch,
|
|
151
|
+
);
|
|
133
152
|
|
|
134
|
-
const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
|
|
153
|
+
const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
|
|
135
154
|
await expect(
|
|
136
155
|
conn.request({ method: "GET", path: "/test" }),
|
|
137
156
|
).rejects.toThrow(CredentialRequiredError);
|
|
138
157
|
});
|
|
139
158
|
|
|
140
159
|
test("502 response throws ProviderUnreachableError", async () => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
const client = makeMockClient(
|
|
161
|
+
mock(
|
|
162
|
+
async () => new Response("", { status: 502 }),
|
|
163
|
+
) as unknown as typeof globalThis.fetch,
|
|
164
|
+
);
|
|
144
165
|
|
|
145
|
-
const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
|
|
166
|
+
const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
|
|
146
167
|
await expect(
|
|
147
168
|
conn.request({ method: "GET", path: "/test" }),
|
|
148
169
|
).rejects.toThrow(ProviderUnreachableError);
|
|
@@ -155,36 +176,24 @@ describe("PlatformOAuthConnection", () => {
|
|
|
155
176
|
);
|
|
156
177
|
});
|
|
157
178
|
|
|
158
|
-
test("
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
...DEFAULT_OPTIONS,
|
|
171
|
-
platformBaseUrl: "https://platform.example.com/",
|
|
172
|
-
});
|
|
173
|
-
await conn.request({ method: "GET", path: "/test" });
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("strips integration: prefix from providerKey for slug", async () => {
|
|
177
|
-
globalThis.fetch = mock(async (url: string | URL | Request) => {
|
|
178
|
-
expect(String(url)).toContain("/external-provider-proxy/slack/");
|
|
179
|
-
return new Response(
|
|
180
|
-
JSON.stringify({ status: 200, headers: {}, body: null }),
|
|
181
|
-
{ status: 200 },
|
|
182
|
-
);
|
|
183
|
-
}) as unknown as typeof globalThis.fetch;
|
|
179
|
+
test("uses connectionId in proxy URL regardless of providerKey format", async () => {
|
|
180
|
+
const client = makeMockClient(
|
|
181
|
+
mock(async (url: string | URL | Request) => {
|
|
182
|
+
expect(String(url)).toContain(
|
|
183
|
+
"/external-provider-proxy/slack-conn-456/",
|
|
184
|
+
);
|
|
185
|
+
return new Response(
|
|
186
|
+
JSON.stringify({ status: 200, headers: {}, body: null }),
|
|
187
|
+
{ status: 200 },
|
|
188
|
+
);
|
|
189
|
+
}) as unknown as typeof globalThis.fetch,
|
|
190
|
+
);
|
|
184
191
|
|
|
185
192
|
const conn = new PlatformOAuthConnection({
|
|
186
193
|
...DEFAULT_OPTIONS,
|
|
194
|
+
client,
|
|
187
195
|
providerKey: "integration:slack",
|
|
196
|
+
connectionId: "slack-conn-456",
|
|
188
197
|
});
|
|
189
198
|
await conn.request({ method: "GET", path: "/test" });
|
|
190
199
|
});
|