@vellumai/assistant 0.4.37 → 0.4.41
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 +3 -3
- package/README.md +13 -13
- package/bun.lock +80 -24
- package/docs/architecture/integrations.md +126 -128
- package/docs/runbook-trusted-contacts.md +1 -1
- package/docs/trusted-contact-access.md +12 -12
- package/package.json +3 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -14
- package/src/__tests__/app-bundler.test.ts +209 -0
- package/src/__tests__/app-compiler.test.ts +279 -0
- package/src/__tests__/app-executors.test.ts +293 -483
- package/src/__tests__/app-migration.test.ts +148 -0
- package/src/__tests__/app-routes-csp.test.ts +202 -0
- package/src/__tests__/avatar-e2e.test.ts +452 -0
- package/src/__tests__/avatar-generator.test.ts +193 -0
- package/src/__tests__/avatar-router.test.ts +186 -0
- package/src/__tests__/browser-download-timeout.test.ts +28 -0
- package/src/__tests__/bundled-skill-retrieval-guard.test.ts +9 -9
- package/src/__tests__/call-domain.test.ts +3 -7
- package/src/__tests__/credential-security-e2e.test.ts +19 -12
- package/src/__tests__/credentials-cli.test.ts +30 -4
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +1 -1
- package/src/__tests__/handlers-slack-config.test.ts +0 -72
- package/src/__tests__/handlers-telegram-config.test.ts +19 -12
- package/src/__tests__/handlers-twitter-config.test.ts +105 -48
- package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
- package/src/__tests__/integration-status.test.ts +15 -5
- package/src/__tests__/integrations-cli.test.ts +1 -1
- package/src/__tests__/invite-redemption-service.test.ts +62 -7
- package/src/__tests__/ipc-snapshot.test.ts +0 -8
- package/src/__tests__/managed-avatar-client.test.ts +280 -0
- package/src/__tests__/mcp-cli.test.ts +3 -3
- package/src/__tests__/oauth-cli.test.ts +203 -0
- package/src/__tests__/relay-server.test.ts +3 -3
- package/src/__tests__/secret-onetime-send.test.ts +19 -12
- package/src/__tests__/secure-keys.test.ts +78 -0
- package/src/__tests__/session-messaging-secret-redirect.test.ts +3 -0
- package/src/__tests__/slack-channel-config.test.ts +23 -16
- package/src/__tests__/slack-share-routes.test.ts +263 -0
- package/src/__tests__/sms-messaging-provider.test.ts +3 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +7 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
- package/src/__tests__/trusted-contact-verification.test.ts +10 -10
- package/src/__tests__/twilio-config.test.ts +15 -36
- package/src/__tests__/twilio-provider.test.ts +4 -0
- package/src/__tests__/twitter-auth-handler.test.ts +27 -14
- package/src/__tests__/twitter-cli-error-shaping.test.ts +1 -1
- package/src/__tests__/twitter-cli-routing.test.ts +38 -53
- package/src/__tests__/twitter-oauth-client.test.ts +18 -47
- package/src/__tests__/voice-invite-redemption.test.ts +27 -3
- package/src/amazon/cart.ts +1 -1
- package/src/amazon/client.ts +89 -7
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/bundler/app-bundler.ts +77 -32
- package/src/bundler/app-compiler.ts +195 -0
- package/src/bundler/manifest.ts +1 -1
- package/src/bundler/package-resolver.ts +185 -0
- package/src/calls/call-domain.ts +4 -14
- package/src/calls/relay-server.ts +2 -2
- package/src/calls/twilio-config.ts +5 -24
- package/src/calls/twilio-rest.ts +19 -5
- package/src/cli/amazon.ts +74 -249
- package/src/cli/audit.ts +2 -2
- package/src/cli/autonomy.ts +9 -9
- package/src/cli/channels.ts +5 -5
- package/src/cli/completions.ts +27 -27
- package/src/cli/config.ts +14 -14
- package/src/cli/contacts.ts +27 -27
- package/src/cli/credentials.ts +28 -28
- package/src/cli/dev.ts +2 -2
- package/src/cli/doctor.ts +2 -2
- package/src/cli/email.ts +82 -82
- package/src/cli/influencer.ts +13 -13
- package/src/cli/integrations.ts +19 -144
- package/src/cli/keys.ts +10 -10
- package/src/cli/map.ts +4 -4
- package/src/cli/mcp.ts +17 -17
- package/src/cli/memory.ts +18 -18
- package/src/cli/notifications.ts +13 -13
- package/src/cli/oauth.ts +77 -0
- package/src/cli/program.ts +2 -0
- package/src/cli/sequence.ts +27 -27
- package/src/cli/sessions.ts +12 -12
- package/src/cli/trust.ts +8 -8
- package/src/cli/twitter.ts +124 -70
- package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
- package/src/config/bundled-skills/agentmail/SKILL.md +34 -34
- package/src/config/bundled-skills/amazon/SKILL.md +54 -54
- package/src/config/bundled-skills/app-builder/SKILL.md +137 -3
- package/src/config/bundled-skills/app-builder/tools/app-create.ts +10 -4
- package/src/config/bundled-skills/configure-settings/SKILL.md +18 -18
- package/src/config/bundled-skills/contacts/SKILL.md +12 -12
- package/src/config/bundled-skills/doordash/lib/client.ts +7 -9
- package/src/config/bundled-skills/email-setup/SKILL.md +4 -4
- package/src/config/bundled-skills/frontend-design/icon.svg +16 -0
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +143 -162
- package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +4 -4
- package/src/config/bundled-skills/influencer/SKILL.md +13 -13
- package/src/config/bundled-skills/mcp-setup/SKILL.md +11 -11
- package/src/config/bundled-skills/phone-calls/SKILL.md +48 -54
- package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
- package/src/config/bundled-skills/slack-app-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/sms-setup/SKILL.md +3 -3
- package/src/config/bundled-skills/telegram-setup/SKILL.md +2 -2
- package/src/config/bundled-skills/twilio-setup/SKILL.md +136 -225
- package/src/config/bundled-skills/twitter/SKILL.md +68 -44
- package/src/config/bundled-skills/voice-setup/SKILL.md +2 -2
- package/src/config/core-schema.ts +26 -0
- package/src/config/env.ts +4 -0
- package/src/config/feature-flag-registry.json +9 -1
- package/src/config/schema.ts +8 -0
- package/src/config/system-prompt.ts +6 -3
- package/src/config/templates/BOOTSTRAP.md +7 -5
- package/src/contacts/contacts-write.ts +5 -1
- package/src/daemon/handlers/apps.ts +31 -4
- package/src/daemon/handlers/config-ingress.ts +3 -3
- package/src/daemon/handlers/config-integrations.ts +120 -49
- package/src/daemon/handlers/config-slack-channel.ts +26 -7
- package/src/daemon/handlers/config-slack.ts +1 -54
- package/src/daemon/handlers/config-telegram.ts +28 -10
- package/src/daemon/handlers/config.ts +1 -4
- package/src/daemon/handlers/twitter-auth.ts +11 -4
- package/src/daemon/ipc-contract/apps.ts +0 -13
- package/src/daemon/ipc-contract-inventory.json +0 -2
- package/src/daemon/lifecycle.ts +8 -1
- package/src/daemon/session-messaging.ts +2 -2
- package/src/daemon/tool-side-effects.ts +30 -0
- package/src/email/providers/agentmail.ts +1 -1
- package/src/email/providers/index.ts +1 -1
- package/src/email/service.ts +1 -1
- package/src/gallery/default-gallery.ts +538 -0
- package/src/gallery/gallery-manifest.ts +5 -1
- package/src/influencer/client.ts +8 -6
- package/src/mcp/client.ts +1 -1
- package/src/media/avatar-router.ts +99 -0
- package/src/media/avatar-types.ts +60 -0
- package/src/media/managed-avatar-client.ts +189 -0
- package/src/memory/app-migration.ts +114 -0
- package/src/memory/app-store.ts +11 -0
- package/src/memory/qdrant-client.ts +1 -1
- package/src/messaging/providers/slack/client.ts +12 -2
- package/src/messaging/providers/sms/adapter.ts +6 -10
- package/src/migrations/data-layout.ts +8 -1
- package/src/oauth/token-persistence.ts +9 -6
- package/src/runtime/assistant-scope.ts +5 -0
- package/src/runtime/auth/route-policy.ts +4 -0
- package/src/runtime/channel-readiness-service.ts +9 -4
- package/src/runtime/gateway-internal-client.ts +11 -3
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/invite-redemption-service.ts +23 -13
- package/src/runtime/middleware/twilio-validation.ts +2 -2
- package/src/runtime/routes/app-routes.ts +131 -3
- package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -3
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/runtime/routes/slack-share-routes.ts +235 -0
- package/src/runtime/routes/twilio-routes.ts +47 -34
- package/src/schedule/integration-status.ts +2 -3
- package/src/security/token-manager.ts +11 -3
- package/src/tools/apps/executors.ts +116 -8
- package/src/tools/browser/browser-manager.ts +30 -2
- package/src/tools/browser/chrome-cdp.ts +31 -3
- package/src/tools/credentials/vault.ts +9 -7
- package/src/tools/executor.ts +4 -0
- package/src/tools/system/avatar-generator.ts +55 -34
- package/src/twitter/client.ts +1 -1
- package/src/twitter/oauth-client.ts +31 -43
- package/src/twitter/router.ts +25 -23
- package/src/util/platform.ts +5 -0
- package/src/slack/slack-webhook.ts +0 -66
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mock dependencies — must be before importing the module under test
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
let mockApiKey: string | undefined = "test-api-key-123";
|
|
8
|
+
let mockBaseUrl = "https://platform.vellum.ai";
|
|
9
|
+
let mockPlatformEnvUrl = "https://env.vellum.ai";
|
|
10
|
+
|
|
11
|
+
mock.module("../security/secure-keys.js", () => ({
|
|
12
|
+
getSecureKey: (account: string) => {
|
|
13
|
+
if (account === "credential:vellum:assistant_api_key") return mockApiKey;
|
|
14
|
+
return undefined;
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
mock.module("../config/loader.js", () => ({
|
|
19
|
+
getConfig: () => ({
|
|
20
|
+
platform: { baseUrl: mockBaseUrl },
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("../config/env.js", () => ({
|
|
25
|
+
getPlatformBaseUrl: () => mockPlatformEnvUrl,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
mock.module("../util/logger.js", () => ({
|
|
29
|
+
getLogger: () => ({
|
|
30
|
+
debug: () => {},
|
|
31
|
+
info: () => {},
|
|
32
|
+
warn: () => {},
|
|
33
|
+
error: () => {},
|
|
34
|
+
}),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// Mock global fetch
|
|
38
|
+
let lastFetchArgs: [string, RequestInit] | null = null;
|
|
39
|
+
let fetchResponse: { ok: boolean; status: number; json: () => Promise<unknown> } = {
|
|
40
|
+
ok: true,
|
|
41
|
+
status: 200,
|
|
42
|
+
json: async () => ({}),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const originalFetch = globalThis.fetch;
|
|
46
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
47
|
+
lastFetchArgs = [url, init];
|
|
48
|
+
return fetchResponse;
|
|
49
|
+
}) as typeof globalThis.fetch;
|
|
50
|
+
|
|
51
|
+
// Import after mocking
|
|
52
|
+
import {
|
|
53
|
+
isManagedAvailable,
|
|
54
|
+
generateManagedAvatar,
|
|
55
|
+
getAssistantApiKey,
|
|
56
|
+
} from "../media/managed-avatar-client.js";
|
|
57
|
+
import {
|
|
58
|
+
ManagedAvatarError,
|
|
59
|
+
AVATAR_PROMPT_MAX_LENGTH,
|
|
60
|
+
AVATAR_MAX_DECODED_BYTES,
|
|
61
|
+
} from "../media/avatar-types.js";
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Helpers
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function successResponse() {
|
|
68
|
+
return {
|
|
69
|
+
image: {
|
|
70
|
+
mime_type: "image/png",
|
|
71
|
+
data_base64: "iVBORw0KGgo=",
|
|
72
|
+
bytes: 1024,
|
|
73
|
+
sha256: "abc123",
|
|
74
|
+
},
|
|
75
|
+
usage: { billable: true, class_name: "avatar" },
|
|
76
|
+
generation_source: "managed",
|
|
77
|
+
profile: "default",
|
|
78
|
+
correlation_id: "test-corr-id",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Tests
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
mockApiKey = "test-api-key-123";
|
|
88
|
+
mockBaseUrl = "https://platform.vellum.ai";
|
|
89
|
+
mockPlatformEnvUrl = "https://env.vellum.ai";
|
|
90
|
+
lastFetchArgs = null;
|
|
91
|
+
fetchResponse = {
|
|
92
|
+
ok: true,
|
|
93
|
+
status: 200,
|
|
94
|
+
json: async () => successResponse(),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("generateManagedAvatar", () => {
|
|
99
|
+
test("successful generation returns parsed response", async () => {
|
|
100
|
+
const result = await generateManagedAvatar("a friendly robot avatar");
|
|
101
|
+
|
|
102
|
+
expect(result.image.mime_type).toBe("image/png");
|
|
103
|
+
expect(result.image.data_base64).toBe("iVBORw0KGgo=");
|
|
104
|
+
expect(result.image.bytes).toBe(1024);
|
|
105
|
+
expect(result.generation_source).toBe("managed");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("prompt exceeding max length throws ManagedAvatarError with code validation_error", async () => {
|
|
109
|
+
const longPrompt = "x".repeat(AVATAR_PROMPT_MAX_LENGTH + 1);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await generateManagedAvatar(longPrompt);
|
|
113
|
+
expect(true).toBe(false); // should not reach here
|
|
114
|
+
} catch (err) {
|
|
115
|
+
expect(err).toBeInstanceOf(ManagedAvatarError);
|
|
116
|
+
const avatarErr = err as ManagedAvatarError;
|
|
117
|
+
expect(avatarErr.code).toBe("validation_error");
|
|
118
|
+
expect(avatarErr.subcode).toBe("prompt_too_long");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("HTTP 429 response throws ManagedAvatarError with retryable true", async () => {
|
|
123
|
+
fetchResponse = {
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 429,
|
|
126
|
+
json: async () => ({
|
|
127
|
+
code: "rate_limit",
|
|
128
|
+
subcode: "too_many_requests",
|
|
129
|
+
detail: "Rate limit exceeded",
|
|
130
|
+
retryable: true,
|
|
131
|
+
correlation_id: "corr-429",
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await generateManagedAvatar("test prompt");
|
|
137
|
+
expect(true).toBe(false);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
expect(err).toBeInstanceOf(ManagedAvatarError);
|
|
140
|
+
const avatarErr = err as ManagedAvatarError;
|
|
141
|
+
expect(avatarErr.retryable).toBe(true);
|
|
142
|
+
expect(avatarErr.statusCode).toBe(429);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("HTTP 500 response throws ManagedAvatarError with upstream error details", async () => {
|
|
147
|
+
fetchResponse = {
|
|
148
|
+
ok: false,
|
|
149
|
+
status: 500,
|
|
150
|
+
json: async () => ({
|
|
151
|
+
code: "internal_error",
|
|
152
|
+
subcode: "server_fault",
|
|
153
|
+
detail: "Internal server error",
|
|
154
|
+
retryable: true,
|
|
155
|
+
correlation_id: "corr-500",
|
|
156
|
+
}),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await generateManagedAvatar("test prompt");
|
|
161
|
+
expect(true).toBe(false);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
expect(err).toBeInstanceOf(ManagedAvatarError);
|
|
164
|
+
const avatarErr = err as ManagedAvatarError;
|
|
165
|
+
expect(avatarErr.code).toBe("internal_error");
|
|
166
|
+
expect(avatarErr.subcode).toBe("server_fault");
|
|
167
|
+
expect(avatarErr.statusCode).toBe(500);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("response with disallowed MIME type throws validation error", async () => {
|
|
172
|
+
fetchResponse = {
|
|
173
|
+
ok: true,
|
|
174
|
+
status: 200,
|
|
175
|
+
json: async () => ({
|
|
176
|
+
...successResponse(),
|
|
177
|
+
image: { ...successResponse().image, mime_type: "image/gif" },
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await generateManagedAvatar("test prompt");
|
|
183
|
+
expect(true).toBe(false);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
expect(err).toBeInstanceOf(ManagedAvatarError);
|
|
186
|
+
const avatarErr = err as ManagedAvatarError;
|
|
187
|
+
expect(avatarErr.code).toBe("validation_error");
|
|
188
|
+
expect(avatarErr.subcode).toBe("disallowed_mime_type");
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("response with oversized bytes throws validation error", async () => {
|
|
193
|
+
fetchResponse = {
|
|
194
|
+
ok: true,
|
|
195
|
+
status: 200,
|
|
196
|
+
json: async () => ({
|
|
197
|
+
...successResponse(),
|
|
198
|
+
image: { ...successResponse().image, bytes: AVATAR_MAX_DECODED_BYTES + 1 },
|
|
199
|
+
}),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await generateManagedAvatar("test prompt");
|
|
204
|
+
expect(true).toBe(false);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
expect(err).toBeInstanceOf(ManagedAvatarError);
|
|
207
|
+
const avatarErr = err as ManagedAvatarError;
|
|
208
|
+
expect(avatarErr.code).toBe("validation_error");
|
|
209
|
+
expect(avatarErr.subcode).toBe("oversized_image");
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("response with oversized base64 estimated decoded size throws validation error", async () => {
|
|
214
|
+
// Create a base64 string whose estimated decoded size exceeds the limit,
|
|
215
|
+
// even though the server-reported bytes field is under the limit
|
|
216
|
+
const oversizedBase64 = "A".repeat(Math.ceil((AVATAR_MAX_DECODED_BYTES + 100) * 4 / 3));
|
|
217
|
+
fetchResponse = {
|
|
218
|
+
ok: true,
|
|
219
|
+
status: 200,
|
|
220
|
+
json: async () => ({
|
|
221
|
+
...successResponse(),
|
|
222
|
+
image: { ...successResponse().image, data_base64: oversizedBase64, bytes: 1024 },
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await generateManagedAvatar("test prompt");
|
|
228
|
+
expect(true).toBe(false);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
expect(err).toBeInstanceOf(ManagedAvatarError);
|
|
231
|
+
const avatarErr = err as ManagedAvatarError;
|
|
232
|
+
expect(avatarErr.code).toBe("validation_error");
|
|
233
|
+
expect(avatarErr.subcode).toBe("oversized_image");
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("Authorization header uses Api-Key prefix", async () => {
|
|
238
|
+
await generateManagedAvatar("test prompt");
|
|
239
|
+
|
|
240
|
+
expect(lastFetchArgs).not.toBeNull();
|
|
241
|
+
const headers = lastFetchArgs![1].headers as Record<string, string>;
|
|
242
|
+
expect(headers.Authorization).toBe("Api-Key test-api-key-123");
|
|
243
|
+
expect(headers.Authorization).not.toContain("Bearer");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("Idempotency-Key header is present on every request", async () => {
|
|
247
|
+
await generateManagedAvatar("test prompt");
|
|
248
|
+
|
|
249
|
+
expect(lastFetchArgs).not.toBeNull();
|
|
250
|
+
const headers = lastFetchArgs![1].headers as Record<string, string>;
|
|
251
|
+
expect(headers["Idempotency-Key"]).toBeDefined();
|
|
252
|
+
expect(headers["Idempotency-Key"].length).toBeGreaterThan(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("caller-provided idempotencyKey is used in the request header", async () => {
|
|
256
|
+
const customKey = "my-custom-idempotency-key-123";
|
|
257
|
+
await generateManagedAvatar("test prompt", { idempotencyKey: customKey });
|
|
258
|
+
|
|
259
|
+
expect(lastFetchArgs).not.toBeNull();
|
|
260
|
+
const headers = lastFetchArgs![1].headers as Record<string, string>;
|
|
261
|
+
expect(headers["Idempotency-Key"]).toBe(customKey);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("isManagedAvailable", () => {
|
|
266
|
+
test("returns false when API key is missing", () => {
|
|
267
|
+
mockApiKey = undefined;
|
|
268
|
+
expect(isManagedAvailable()).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("returns false when base URL is missing", () => {
|
|
272
|
+
mockBaseUrl = "";
|
|
273
|
+
mockPlatformEnvUrl = "";
|
|
274
|
+
expect(isManagedAvailable()).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("returns true when both API key and base URL are present", () => {
|
|
278
|
+
expect(isManagedAvailable()).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -131,7 +131,7 @@ async function runMcpRemove(name: string) {
|
|
|
131
131
|
|
|
132
132
|
// ── Tests ─────────────────────────────────────────────────────────────
|
|
133
133
|
|
|
134
|
-
describe("
|
|
134
|
+
describe("assistant mcp list", () => {
|
|
135
135
|
beforeAll(() => {
|
|
136
136
|
testDataDir = join(
|
|
137
137
|
tmpdir(),
|
|
@@ -258,7 +258,7 @@ describe("vellum mcp list", () => {
|
|
|
258
258
|
});
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
-
describe("
|
|
261
|
+
describe("assistant mcp add", () => {
|
|
262
262
|
beforeAll(() => {
|
|
263
263
|
testDataDir = join(
|
|
264
264
|
tmpdir(),
|
|
@@ -396,7 +396,7 @@ describe("vellum mcp add", () => {
|
|
|
396
396
|
});
|
|
397
397
|
});
|
|
398
398
|
|
|
399
|
-
describe("
|
|
399
|
+
describe("assistant mcp remove", () => {
|
|
400
400
|
beforeAll(() => {
|
|
401
401
|
testDataDir = join(
|
|
402
402
|
tmpdir(),
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mock state
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
let mockWithValidToken: <T>(
|
|
10
|
+
service: string,
|
|
11
|
+
cb: (token: string) => Promise<T>,
|
|
12
|
+
) => Promise<T>;
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Mock token-manager
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
mock.module("../security/token-manager.js", () => ({
|
|
19
|
+
withValidToken: <T>(
|
|
20
|
+
service: string,
|
|
21
|
+
cb: (token: string) => Promise<T>,
|
|
22
|
+
): Promise<T> => mockWithValidToken(service, cb),
|
|
23
|
+
// Stubs for any transitive imports that reference other exports:
|
|
24
|
+
TokenExpiredError: class TokenExpiredError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
public readonly service: string,
|
|
27
|
+
message?: string,
|
|
28
|
+
) {
|
|
29
|
+
super(message ?? `Token expired for "${service}".`);
|
|
30
|
+
this.name = "TokenExpiredError";
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Stub out transitive dependencies that token-manager would normally pull in
|
|
36
|
+
mock.module("../security/secure-keys.js", () => ({
|
|
37
|
+
getSecureKey: () => undefined,
|
|
38
|
+
setSecureKey: () => true,
|
|
39
|
+
getSecureKeyAsync: async () => undefined,
|
|
40
|
+
setSecureKeyAsync: async () => true,
|
|
41
|
+
deleteSecureKey: () => "not-found",
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
mock.module("../tools/credentials/metadata-store.js", () => ({
|
|
45
|
+
getCredentialMetadata: () => undefined,
|
|
46
|
+
upsertCredentialMetadata: () => ({}),
|
|
47
|
+
listCredentialMetadata: () => [],
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
mock.module("../util/logger.js", () => ({
|
|
51
|
+
getLogger: () => ({
|
|
52
|
+
info: () => {},
|
|
53
|
+
warn: () => {},
|
|
54
|
+
error: () => {},
|
|
55
|
+
debug: () => {},
|
|
56
|
+
}),
|
|
57
|
+
getCliLogger: () => ({
|
|
58
|
+
info: () => {},
|
|
59
|
+
warn: () => {},
|
|
60
|
+
error: () => {},
|
|
61
|
+
debug: () => {},
|
|
62
|
+
}),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Import the module under test (after mocks are registered)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const { registerOAuthCommand } = await import("../cli/oauth.js");
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Test helper
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
async function runCli(
|
|
76
|
+
args: string[],
|
|
77
|
+
): Promise<{ exitCode: number; stdout: string }> {
|
|
78
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
79
|
+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
80
|
+
const stdoutChunks: string[] = [];
|
|
81
|
+
|
|
82
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
83
|
+
stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk));
|
|
84
|
+
return true;
|
|
85
|
+
}) as typeof process.stdout.write;
|
|
86
|
+
|
|
87
|
+
process.stderr.write = (() => true) as typeof process.stderr.write;
|
|
88
|
+
|
|
89
|
+
process.exitCode = 0;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const program = new Command();
|
|
93
|
+
program.exitOverride();
|
|
94
|
+
program.configureOutput({
|
|
95
|
+
writeErr: () => {},
|
|
96
|
+
writeOut: (str: string) => stdoutChunks.push(str),
|
|
97
|
+
});
|
|
98
|
+
registerOAuthCommand(program);
|
|
99
|
+
await program.parseAsync(["node", "assistant", "oauth", ...args]);
|
|
100
|
+
} catch {
|
|
101
|
+
if (process.exitCode === 0) process.exitCode = 1;
|
|
102
|
+
} finally {
|
|
103
|
+
process.stdout.write = originalStdoutWrite;
|
|
104
|
+
process.stderr.write = originalStderrWrite;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const exitCode = process.exitCode ?? 0;
|
|
108
|
+
process.exitCode = 0;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
exitCode,
|
|
112
|
+
stdout: stdoutChunks.join(""),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Tests
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe("assistant oauth token", () => {
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("prints bare token in human mode", async () => {
|
|
126
|
+
const { exitCode, stdout } = await runCli(["token", "twitter"]);
|
|
127
|
+
expect(exitCode).toBe(0);
|
|
128
|
+
expect(stdout).toBe("mock-access-token-xyz\n");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("prints JSON in --json mode", async () => {
|
|
132
|
+
const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]);
|
|
133
|
+
expect(exitCode).toBe(0);
|
|
134
|
+
const parsed = JSON.parse(stdout);
|
|
135
|
+
expect(parsed).toEqual({ ok: true, token: "mock-access-token-xyz" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("qualifies service name with integration: prefix", async () => {
|
|
139
|
+
let capturedService: string | undefined;
|
|
140
|
+
mockWithValidToken = async (service, cb) => {
|
|
141
|
+
capturedService = service;
|
|
142
|
+
return cb("tok");
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
await runCli(["token", "twitter"]);
|
|
146
|
+
expect(capturedService).toBe("integration:twitter");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("works with other service names", async () => {
|
|
150
|
+
let capturedService: string | undefined;
|
|
151
|
+
mockWithValidToken = async (service, cb) => {
|
|
152
|
+
capturedService = service;
|
|
153
|
+
return cb("gmail-token");
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const { exitCode, stdout } = await runCli(["token", "gmail"]);
|
|
157
|
+
expect(exitCode).toBe(0);
|
|
158
|
+
expect(stdout).toBe("gmail-token\n");
|
|
159
|
+
expect(capturedService).toBe("integration:gmail");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("exits 1 when no token exists", async () => {
|
|
163
|
+
mockWithValidToken = async () => {
|
|
164
|
+
throw new Error(
|
|
165
|
+
'No access token found for "integration:twitter". Authorization required.',
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]);
|
|
170
|
+
expect(exitCode).toBe(1);
|
|
171
|
+
const parsed = JSON.parse(stdout);
|
|
172
|
+
expect(parsed.ok).toBe(false);
|
|
173
|
+
expect(parsed.error).toContain("No access token found");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("exits 1 when refresh fails", async () => {
|
|
177
|
+
mockWithValidToken = async () => {
|
|
178
|
+
throw new Error(
|
|
179
|
+
'Token refresh failed for "integration:twitter": invalid_grant.',
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]);
|
|
184
|
+
expect(exitCode).toBe(1);
|
|
185
|
+
const parsed = JSON.parse(stdout);
|
|
186
|
+
expect(parsed.ok).toBe(false);
|
|
187
|
+
expect(parsed.error).toContain("Token refresh failed");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("returns refreshed token transparently", async () => {
|
|
191
|
+
// Simulate withValidToken refreshing and returning a new token
|
|
192
|
+
mockWithValidToken = async (_service, cb) => cb("refreshed-new-token");
|
|
193
|
+
|
|
194
|
+
const { exitCode, stdout } = await runCli(["token", "twitter"]);
|
|
195
|
+
expect(exitCode).toBe(0);
|
|
196
|
+
expect(stdout).toBe("refreshed-new-token\n");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("missing service argument exits non-zero", async () => {
|
|
200
|
+
const { exitCode } = await runCli(["token"]);
|
|
201
|
+
expect(exitCode).not.toBe(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -192,7 +192,7 @@ import {
|
|
|
192
192
|
import { setVoiceBridgeDeps } from "../calls/voice-session-bridge.js";
|
|
193
193
|
import {
|
|
194
194
|
createGuardianBinding,
|
|
195
|
-
|
|
195
|
+
upsertContactChannel,
|
|
196
196
|
} from "../contacts/contacts-write.js";
|
|
197
197
|
import {
|
|
198
198
|
listCanonicalGuardianRequests,
|
|
@@ -291,7 +291,7 @@ function resetTables() {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
function addTrustedVoiceContact(phoneNumber: string): void {
|
|
294
|
-
|
|
294
|
+
upsertContactChannel({
|
|
295
295
|
sourceChannel: "voice",
|
|
296
296
|
externalUserId: phoneNumber,
|
|
297
297
|
externalChatId: phoneNumber,
|
|
@@ -2452,7 +2452,7 @@ describe("relay-server", () => {
|
|
|
2452
2452
|
});
|
|
2453
2453
|
|
|
2454
2454
|
// Create a blocked member
|
|
2455
|
-
|
|
2455
|
+
upsertContactChannel({
|
|
2456
2456
|
sourceChannel: "voice",
|
|
2457
2457
|
externalUserId: "+15558881111",
|
|
2458
2458
|
externalChatId: "+15558881111",
|
|
@@ -29,23 +29,30 @@ mock.module("../util/logger.js", () => ({
|
|
|
29
29
|
|
|
30
30
|
// Track keychain writes
|
|
31
31
|
const storedKeys = new Map<string, string>();
|
|
32
|
-
mock.module("../security/secure-keys.js", () =>
|
|
33
|
-
|
|
34
|
-
setSecureKey: (key: string, value: string) => {
|
|
32
|
+
mock.module("../security/secure-keys.js", () => {
|
|
33
|
+
const syncSet = (key: string, value: string) => {
|
|
35
34
|
storedKeys.set(key, value);
|
|
36
35
|
return true;
|
|
37
|
-
}
|
|
38
|
-
|
|
36
|
+
};
|
|
37
|
+
const syncDelete = (key: string) => {
|
|
39
38
|
if (storedKeys.has(key)) {
|
|
40
39
|
storedKeys.delete(key);
|
|
41
|
-
return "deleted";
|
|
40
|
+
return "deleted" as const;
|
|
42
41
|
}
|
|
43
|
-
return "not-found";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
return "not-found" as const;
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
getSecureKey: (key: string) => storedKeys.get(key) ?? null,
|
|
46
|
+
setSecureKey: syncSet,
|
|
47
|
+
setSecureKeyAsync: async (key: string, value: string) =>
|
|
48
|
+
syncSet(key, value),
|
|
49
|
+
deleteSecureKey: syncDelete,
|
|
50
|
+
deleteSecureKeyAsync: async (key: string) => syncDelete(key),
|
|
51
|
+
listSecureKeys: () => [],
|
|
52
|
+
getBackendType: () => "encrypted",
|
|
53
|
+
isDowngradedFromKeychain: () => false,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
49
56
|
|
|
50
57
|
mock.module("./metadata-store.js", () => ({
|
|
51
58
|
upsertCredentialMetadata: () => {},
|
|
@@ -302,6 +302,84 @@ describe("secure-keys", () => {
|
|
|
302
302
|
});
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
// -----------------------------------------------------------------------
|
|
306
|
+
// Stale-value prevention — broker-first reads after credential updates
|
|
307
|
+
// -----------------------------------------------------------------------
|
|
308
|
+
describe("stale-value prevention", () => {
|
|
309
|
+
test("setSecureKeyAsync updates broker so broker-first read returns new value", async () => {
|
|
310
|
+
mockBrokerAvailable = true;
|
|
311
|
+
// Simulate broker holding an old value
|
|
312
|
+
mockBrokerStore.set("api-key", "old-broker-value");
|
|
313
|
+
setSecureKey("api-key", "old-encrypted-value");
|
|
314
|
+
|
|
315
|
+
// Update via async path (writes both broker + encrypted)
|
|
316
|
+
const ok = await setSecureKeyAsync("api-key", "new-value");
|
|
317
|
+
expect(ok).toBe(true);
|
|
318
|
+
|
|
319
|
+
// Broker-first read should return the new value, not stale old value
|
|
320
|
+
const value = await getSecureKeyAsync("api-key");
|
|
321
|
+
expect(value).toBe("new-value");
|
|
322
|
+
// Both stores should agree
|
|
323
|
+
expect(mockBrokerStore.get("api-key")).toBe("new-value");
|
|
324
|
+
expect(getSecureKey("api-key")).toBe("new-value");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("deleteSecureKeyAsync removes from broker so broker-first read falls through", async () => {
|
|
328
|
+
mockBrokerAvailable = true;
|
|
329
|
+
mockBrokerStore.set("api-key", "old-broker-value");
|
|
330
|
+
setSecureKey("api-key", "old-encrypted-value");
|
|
331
|
+
|
|
332
|
+
// Delete via async path (deletes from both broker + encrypted)
|
|
333
|
+
const result = await deleteSecureKeyAsync("api-key");
|
|
334
|
+
expect(result).toBe("deleted");
|
|
335
|
+
|
|
336
|
+
// Broker-first read should not find the key in either store
|
|
337
|
+
const value = await getSecureKeyAsync("api-key");
|
|
338
|
+
expect(value).toBeUndefined();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("sync setSecureKey does NOT update broker — stale read demonstrates the problem", async () => {
|
|
342
|
+
mockBrokerAvailable = true;
|
|
343
|
+
mockBrokerStore.set("api-key", "old-broker-value");
|
|
344
|
+
|
|
345
|
+
// Sync write only updates encrypted store, NOT broker
|
|
346
|
+
setSecureKey("api-key", "new-encrypted-value");
|
|
347
|
+
|
|
348
|
+
// Broker-first read still returns the stale broker value
|
|
349
|
+
const value = await getSecureKeyAsync("api-key");
|
|
350
|
+
expect(value).toBe("old-broker-value");
|
|
351
|
+
// This is the exact bug that async migration fixes
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("setSecureKeyAsync failure leaves both stores unchanged", async () => {
|
|
355
|
+
mockBrokerAvailable = true;
|
|
356
|
+
mockBrokerSetError = true;
|
|
357
|
+
mockBrokerStore.set("api-key", "original-value");
|
|
358
|
+
setSecureKey("api-key", "original-value");
|
|
359
|
+
|
|
360
|
+
const ok = await setSecureKeyAsync("api-key", "new-value");
|
|
361
|
+
expect(ok).toBe(false);
|
|
362
|
+
|
|
363
|
+
// Both stores should retain original value — no partial update
|
|
364
|
+
expect(mockBrokerStore.get("api-key")).toBe("original-value");
|
|
365
|
+
expect(getSecureKey("api-key")).toBe("original-value");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("deleteSecureKeyAsync failure leaves both stores unchanged", async () => {
|
|
369
|
+
mockBrokerAvailable = true;
|
|
370
|
+
mockBrokerDelError = true;
|
|
371
|
+
mockBrokerStore.set("api-key", "value");
|
|
372
|
+
setSecureKey("api-key", "value");
|
|
373
|
+
|
|
374
|
+
const result = await deleteSecureKeyAsync("api-key");
|
|
375
|
+
expect(result).toBe("error");
|
|
376
|
+
|
|
377
|
+
// Both stores should retain the key — no partial deletion
|
|
378
|
+
expect(mockBrokerStore.has("api-key")).toBe(true);
|
|
379
|
+
expect(getSecureKey("api-key")).toBe("value");
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
305
383
|
// -----------------------------------------------------------------------
|
|
306
384
|
// _setBackend / _resetBackend (no-ops kept for test compat)
|
|
307
385
|
// -----------------------------------------------------------------------
|
|
@@ -26,7 +26,10 @@ const metadataByKey = new Map<
|
|
|
26
26
|
mock.module("../security/secure-keys.js", () => ({
|
|
27
27
|
getSecureKey: () => undefined,
|
|
28
28
|
setSecureKey: setSecureKeyMock,
|
|
29
|
+
setSecureKeyAsync: async (key?: string, value?: string) =>
|
|
30
|
+
setSecureKeyMock(key, value),
|
|
29
31
|
deleteSecureKey: () => "deleted",
|
|
32
|
+
deleteSecureKeyAsync: async () => "deleted" as const,
|
|
30
33
|
listSecureKeys: () => [],
|
|
31
34
|
getBackendType: () => null,
|
|
32
35
|
isDowngradedFromKeychain: () => false,
|