@vellumai/cli 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -32
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +13 -3
- package/src/__tests__/config-utils.test.ts +31 -1
- package/src/__tests__/hatch-provider-secrets.test.ts +284 -0
- package/src/__tests__/input-history.test.ts +102 -0
- package/src/__tests__/preload.ts +5 -1
- package/src/__tests__/provider-secrets.test.ts +290 -0
- package/src/__tests__/setup.test.ts +360 -0
- package/src/__tests__/teleport.test.ts +191 -163
- package/src/commands/client.ts +57 -1
- package/src/commands/hatch.ts +53 -20
- package/src/commands/setup.ts +134 -95
- package/src/commands/teleport.ts +20 -2
- package/src/components/DefaultMainScreen.tsx +72 -119
- package/src/lib/__tests__/docker.test.ts +106 -0
- package/src/lib/assistant-config.ts +6 -2
- package/src/lib/config-utils.ts +18 -0
- package/src/lib/docker.ts +180 -19
- package/src/lib/environments/paths.ts +21 -0
- package/src/lib/hatch-local.ts +42 -3
- package/src/lib/hatch-next-steps.ts +12 -0
- package/src/lib/input-history.ts +5 -8
- package/src/lib/provider-secrets.ts +564 -0
- package/src/lib/sync-cloud-assistants.ts +23 -9
- package/src/lib/doctor-client.ts +0 -153
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ensureProviderApiKey,
|
|
6
|
+
injectGatewayApiKey,
|
|
7
|
+
promptSecret,
|
|
8
|
+
readGatewayApiKey,
|
|
9
|
+
type ProviderSecretFetch,
|
|
10
|
+
} from "../lib/provider-secrets.js";
|
|
11
|
+
|
|
12
|
+
interface RecordedFetchCall {
|
|
13
|
+
url: string;
|
|
14
|
+
init?: RequestInit;
|
|
15
|
+
body: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
19
|
+
return new Response(JSON.stringify(body), {
|
|
20
|
+
status,
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeFetch(responses: Response[]): {
|
|
26
|
+
calls: RecordedFetchCall[];
|
|
27
|
+
fetchImpl: ProviderSecretFetch;
|
|
28
|
+
} {
|
|
29
|
+
const calls: RecordedFetchCall[] = [];
|
|
30
|
+
const fetchImpl: ProviderSecretFetch = async (input, init) => {
|
|
31
|
+
calls.push({
|
|
32
|
+
url: String(input),
|
|
33
|
+
init,
|
|
34
|
+
body: typeof init?.body === "string" ? JSON.parse(init.body) : init?.body,
|
|
35
|
+
});
|
|
36
|
+
const response = responses.shift();
|
|
37
|
+
if (!response) {
|
|
38
|
+
throw new Error("Unexpected fetch call.");
|
|
39
|
+
}
|
|
40
|
+
return response;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return { calls, fetchImpl };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class FakePromptInput extends EventEmitter {
|
|
47
|
+
isTTY = true;
|
|
48
|
+
isRaw = false;
|
|
49
|
+
private paused = false;
|
|
50
|
+
pauseCount = 0;
|
|
51
|
+
rawModes: boolean[] = [];
|
|
52
|
+
|
|
53
|
+
isPaused(): boolean {
|
|
54
|
+
return this.paused;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
resume(): this {
|
|
58
|
+
this.paused = false;
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pause(): this {
|
|
63
|
+
this.paused = true;
|
|
64
|
+
this.pauseCount += 1;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setRawMode(value: boolean): this {
|
|
69
|
+
this.isRaw = value;
|
|
70
|
+
this.rawModes.push(value);
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("provider secret helpers", () => {
|
|
76
|
+
test("reads provider keys from the api_key namespace", async () => {
|
|
77
|
+
const { calls, fetchImpl } = makeFetch([jsonResponse({ found: true })]);
|
|
78
|
+
|
|
79
|
+
await readGatewayApiKey(
|
|
80
|
+
"http://127.0.0.1:3000/",
|
|
81
|
+
"anthropic",
|
|
82
|
+
"guardian-token",
|
|
83
|
+
fetchImpl,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(calls).toHaveLength(1);
|
|
87
|
+
expect(calls[0].url).toBe("http://127.0.0.1:3000/v1/secrets/read");
|
|
88
|
+
expect(calls[0].init?.method).toBe("POST");
|
|
89
|
+
expect(calls[0].init?.headers).toMatchObject({
|
|
90
|
+
Authorization: "Bearer guardian-token",
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
});
|
|
93
|
+
expect(calls[0].body).toEqual({
|
|
94
|
+
type: "api_key",
|
|
95
|
+
name: "anthropic",
|
|
96
|
+
reveal: false,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("explains a missing secret route as a wrong active assistant URL", async () => {
|
|
101
|
+
const { fetchImpl } = makeFetch([
|
|
102
|
+
jsonResponse(
|
|
103
|
+
{
|
|
104
|
+
error: {
|
|
105
|
+
code: "not_found",
|
|
106
|
+
message: "Not found",
|
|
107
|
+
path: "/v1/secrets/read",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
404,
|
|
111
|
+
),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
await expect(
|
|
115
|
+
readGatewayApiKey(
|
|
116
|
+
"https://platform.vellum.ai",
|
|
117
|
+
"anthropic",
|
|
118
|
+
undefined,
|
|
119
|
+
fetchImpl,
|
|
120
|
+
),
|
|
121
|
+
).rejects.toThrow("does not expose /v1/secrets/read");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("injects provider keys into the api_key namespace", async () => {
|
|
125
|
+
const { calls, fetchImpl } = makeFetch([jsonResponse({ success: true })]);
|
|
126
|
+
|
|
127
|
+
await injectGatewayApiKey(
|
|
128
|
+
"http://127.0.0.1:3000",
|
|
129
|
+
"openai",
|
|
130
|
+
"test-provider-key",
|
|
131
|
+
"guardian-token",
|
|
132
|
+
fetchImpl,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(calls).toHaveLength(1);
|
|
136
|
+
expect(calls[0].url).toBe("http://127.0.0.1:3000/v1/secrets");
|
|
137
|
+
expect(calls[0].body).toEqual({
|
|
138
|
+
type: "api_key",
|
|
139
|
+
name: "openai",
|
|
140
|
+
value: "test-provider-key",
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("does not prompt or rewrite an existing provider key", async () => {
|
|
145
|
+
const { calls, fetchImpl } = makeFetch([jsonResponse({ found: true })]);
|
|
146
|
+
let prompted = false;
|
|
147
|
+
|
|
148
|
+
const result = await ensureProviderApiKey({
|
|
149
|
+
gatewayUrl: "http://127.0.0.1:3000",
|
|
150
|
+
provider: "anthropic",
|
|
151
|
+
env: { ANTHROPIC_API_KEY: "test-provider-key" },
|
|
152
|
+
fetchImpl,
|
|
153
|
+
prompt: async () => {
|
|
154
|
+
prompted = true;
|
|
155
|
+
return "unused";
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(result).toEqual({
|
|
160
|
+
status: "already_configured",
|
|
161
|
+
provider: "anthropic",
|
|
162
|
+
});
|
|
163
|
+
expect(prompted).toBe(false);
|
|
164
|
+
expect(calls).toHaveLength(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("stores a provider key from the matching environment variable", async () => {
|
|
168
|
+
const { calls, fetchImpl } = makeFetch([
|
|
169
|
+
jsonResponse({ found: false }),
|
|
170
|
+
jsonResponse({ success: true }),
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const result = await ensureProviderApiKey({
|
|
174
|
+
gatewayUrl: "http://127.0.0.1:3000",
|
|
175
|
+
provider: "anthropic",
|
|
176
|
+
env: { ANTHROPIC_API_KEY: " test-provider-key " },
|
|
177
|
+
fetchImpl,
|
|
178
|
+
stdinIsTTY: false,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(result).toEqual({
|
|
182
|
+
status: "configured",
|
|
183
|
+
provider: "anthropic",
|
|
184
|
+
source: "env",
|
|
185
|
+
});
|
|
186
|
+
expect(calls).toHaveLength(2);
|
|
187
|
+
expect(calls[1].body).toEqual({
|
|
188
|
+
type: "api_key",
|
|
189
|
+
name: "anthropic",
|
|
190
|
+
value: "test-provider-key",
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("prompts when no matching provider key is in the environment", async () => {
|
|
195
|
+
const { calls, fetchImpl } = makeFetch([
|
|
196
|
+
jsonResponse({ found: false }),
|
|
197
|
+
jsonResponse({ success: true }),
|
|
198
|
+
]);
|
|
199
|
+
let promptText = "";
|
|
200
|
+
|
|
201
|
+
const result = await ensureProviderApiKey({
|
|
202
|
+
gatewayUrl: "http://127.0.0.1:3000",
|
|
203
|
+
provider: "openai",
|
|
204
|
+
env: {},
|
|
205
|
+
fetchImpl,
|
|
206
|
+
prompt: async (prompt) => {
|
|
207
|
+
promptText = prompt;
|
|
208
|
+
return "test-openai-key";
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result).toEqual({
|
|
213
|
+
status: "configured",
|
|
214
|
+
provider: "openai",
|
|
215
|
+
source: "prompt",
|
|
216
|
+
});
|
|
217
|
+
expect(promptText).toContain("OpenAI");
|
|
218
|
+
expect(promptText).toContain("OPENAI_API_KEY");
|
|
219
|
+
expect(calls[1].body).toEqual({
|
|
220
|
+
type: "api_key",
|
|
221
|
+
name: "openai",
|
|
222
|
+
value: "test-openai-key",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("pauses prompt input after reading a secret", async () => {
|
|
227
|
+
const input = new FakePromptInput();
|
|
228
|
+
let outputText = "";
|
|
229
|
+
const output = {
|
|
230
|
+
write: (text: string) => {
|
|
231
|
+
outputText += text;
|
|
232
|
+
return true;
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const resultPromise = promptSecret("Enter key: ", {
|
|
237
|
+
input: input as unknown as NodeJS.ReadStream,
|
|
238
|
+
output: output as unknown as NodeJS.WriteStream,
|
|
239
|
+
});
|
|
240
|
+
input.emit("data", Buffer.from("test-provider-key\n"));
|
|
241
|
+
|
|
242
|
+
await expect(resultPromise).resolves.toBe("test-provider-key");
|
|
243
|
+
expect(input.pauseCount).toBe(1);
|
|
244
|
+
expect(input.listenerCount("data")).toBe(0);
|
|
245
|
+
expect(input.rawModes).toEqual([true, false]);
|
|
246
|
+
expect(outputText).toBe("Enter key: \n");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("returns a missing-key result in non-interactive shells", async () => {
|
|
250
|
+
const { calls, fetchImpl } = makeFetch([jsonResponse({ found: false })]);
|
|
251
|
+
|
|
252
|
+
const result = await ensureProviderApiKey({
|
|
253
|
+
gatewayUrl: "http://127.0.0.1:3000",
|
|
254
|
+
provider: "anthropic",
|
|
255
|
+
env: {},
|
|
256
|
+
fetchImpl,
|
|
257
|
+
stdinIsTTY: false,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(result).toEqual({
|
|
261
|
+
status: "missing",
|
|
262
|
+
provider: "anthropic",
|
|
263
|
+
message:
|
|
264
|
+
"Missing ANTHROPIC_API_KEY. Set it in the environment or run vellum setup from an interactive terminal.",
|
|
265
|
+
});
|
|
266
|
+
expect(calls).toHaveLength(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("reports an unavailable credential store without prompting", async () => {
|
|
270
|
+
const { calls, fetchImpl } = makeFetch([
|
|
271
|
+
jsonResponse({ found: false, unreachable: true }),
|
|
272
|
+
]);
|
|
273
|
+
let prompted = false;
|
|
274
|
+
|
|
275
|
+
const result = await ensureProviderApiKey({
|
|
276
|
+
gatewayUrl: "http://127.0.0.1:3000",
|
|
277
|
+
provider: "anthropic",
|
|
278
|
+
env: {},
|
|
279
|
+
fetchImpl,
|
|
280
|
+
prompt: async () => {
|
|
281
|
+
prompted = true;
|
|
282
|
+
return "unused";
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(result.status).toBe("failed");
|
|
287
|
+
expect(prompted).toBe(false);
|
|
288
|
+
expect(calls).toHaveLength(1);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
spyOn,
|
|
7
|
+
test,
|
|
8
|
+
type Mock,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
15
|
+
import {
|
|
16
|
+
saveGuardianToken,
|
|
17
|
+
type GuardianTokenData,
|
|
18
|
+
} from "../lib/guardian-token.js";
|
|
19
|
+
import { setup } from "../commands/setup.js";
|
|
20
|
+
|
|
21
|
+
interface RecordedFetchCall {
|
|
22
|
+
url: string;
|
|
23
|
+
method?: string;
|
|
24
|
+
headers: Headers;
|
|
25
|
+
body: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const originalArgv = [...process.argv];
|
|
29
|
+
const originalFetch = globalThis.fetch;
|
|
30
|
+
const originalEnv = {
|
|
31
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
|
32
|
+
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
33
|
+
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
34
|
+
xdgDataHome: process.env.XDG_DATA_HOME,
|
|
35
|
+
vellumEnvironment: process.env.VELLUM_ENVIRONMENT,
|
|
36
|
+
vellumLockfileDir: process.env.VELLUM_LOCKFILE_DIR,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let testDir = "";
|
|
40
|
+
let fetchCalls: RecordedFetchCall[] = [];
|
|
41
|
+
let consoleLogSpy: Mock<(...args: unknown[]) => void>;
|
|
42
|
+
let consoleErrorSpy: Mock<(...args: unknown[]) => void>;
|
|
43
|
+
|
|
44
|
+
function guardianTokenFixture(
|
|
45
|
+
overrides: Partial<GuardianTokenData> = {},
|
|
46
|
+
): GuardianTokenData {
|
|
47
|
+
return {
|
|
48
|
+
guardianPrincipalId: "guardian-principal-123",
|
|
49
|
+
accessToken: "guardian-token",
|
|
50
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
51
|
+
refreshToken: "refresh-token",
|
|
52
|
+
refreshTokenExpiresAt: new Date(Date.now() + 120_000).toISOString(),
|
|
53
|
+
refreshAfter: new Date(Date.now() + 30_000).toISOString(),
|
|
54
|
+
isNew: false,
|
|
55
|
+
deviceId: "device-123",
|
|
56
|
+
leasedAt: new Date().toISOString(),
|
|
57
|
+
...overrides,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeLockfile(entry: AssistantEntry): void {
|
|
62
|
+
const lockfileDir = process.env.VELLUM_LOCKFILE_DIR!;
|
|
63
|
+
mkdirSync(lockfileDir, { recursive: true });
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(lockfileDir, ".vellum.lock.json"),
|
|
66
|
+
JSON.stringify(
|
|
67
|
+
{
|
|
68
|
+
assistants: [entry],
|
|
69
|
+
activeAssistant: entry.assistantId,
|
|
70
|
+
},
|
|
71
|
+
null,
|
|
72
|
+
2,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function installFetchStub(
|
|
78
|
+
options: {
|
|
79
|
+
leasedToken?: GuardianTokenData;
|
|
80
|
+
refreshedToken?: GuardianTokenData;
|
|
81
|
+
} = {},
|
|
82
|
+
) {
|
|
83
|
+
fetchCalls = [];
|
|
84
|
+
globalThis.fetch = (async (input, init) => {
|
|
85
|
+
const headers = new Headers(init?.headers);
|
|
86
|
+
const body =
|
|
87
|
+
typeof init?.body === "string" ? JSON.parse(init.body) : init?.body;
|
|
88
|
+
const url = String(input);
|
|
89
|
+
fetchCalls.push({
|
|
90
|
+
url,
|
|
91
|
+
method: init?.method,
|
|
92
|
+
headers,
|
|
93
|
+
body,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (url.endsWith("/v1/guardian/refresh")) {
|
|
97
|
+
if (!options.refreshedToken) {
|
|
98
|
+
return new Response(JSON.stringify({ error: "expired" }), {
|
|
99
|
+
status: 401,
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return new Response(JSON.stringify(options.refreshedToken), {
|
|
104
|
+
status: 200,
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (url.endsWith("/v1/guardian/init")) {
|
|
110
|
+
if (!options.leasedToken) {
|
|
111
|
+
return new Response(JSON.stringify({ error: "not allowed" }), {
|
|
112
|
+
status: 401,
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return new Response(JSON.stringify(options.leasedToken), {
|
|
117
|
+
status: 200,
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (url.endsWith("/v1/secrets/read")) {
|
|
123
|
+
return new Response(JSON.stringify({ found: false }), {
|
|
124
|
+
status: 200,
|
|
125
|
+
headers: { "Content-Type": "application/json" },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (url.endsWith("/v1/secrets")) {
|
|
130
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
131
|
+
status: 200,
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return new Response(JSON.stringify({ error: "Unexpected URL" }), {
|
|
137
|
+
status: 500,
|
|
138
|
+
headers: { "Content-Type": "application/json" },
|
|
139
|
+
});
|
|
140
|
+
}) as typeof fetch;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function secretWriteCall(): RecordedFetchCall {
|
|
144
|
+
const call = fetchCalls.find((record) => record.url.endsWith("/v1/secrets"));
|
|
145
|
+
if (!call) {
|
|
146
|
+
throw new Error("Expected /v1/secrets call.");
|
|
147
|
+
}
|
|
148
|
+
return call;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("setup command", () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
testDir = mkdtempSync(join(tmpdir(), "vellum-setup-test-"));
|
|
154
|
+
process.argv = ["bun", "vellum", "setup"];
|
|
155
|
+
process.env.XDG_CONFIG_HOME = join(testDir, "config");
|
|
156
|
+
process.env.XDG_DATA_HOME = join(testDir, "data");
|
|
157
|
+
process.env.VELLUM_LOCKFILE_DIR = join(testDir, "lockfile");
|
|
158
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
159
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
160
|
+
delete process.env.OPENAI_API_KEY;
|
|
161
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
162
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
163
|
+
installFetchStub();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
process.argv = originalArgv;
|
|
168
|
+
globalThis.fetch = originalFetch;
|
|
169
|
+
setOptionalEnv("ANTHROPIC_API_KEY", originalEnv.anthropicApiKey);
|
|
170
|
+
setOptionalEnv("OPENAI_API_KEY", originalEnv.openaiApiKey);
|
|
171
|
+
setOptionalEnv("XDG_CONFIG_HOME", originalEnv.xdgConfigHome);
|
|
172
|
+
setOptionalEnv("XDG_DATA_HOME", originalEnv.xdgDataHome);
|
|
173
|
+
setOptionalEnv("VELLUM_ENVIRONMENT", originalEnv.vellumEnvironment);
|
|
174
|
+
setOptionalEnv("VELLUM_LOCKFILE_DIR", originalEnv.vellumLockfileDir);
|
|
175
|
+
consoleLogSpy.mockRestore();
|
|
176
|
+
consoleErrorSpy.mockRestore();
|
|
177
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("configures the default provider through the active assistant gateway", async () => {
|
|
181
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
182
|
+
writeLockfile({
|
|
183
|
+
assistantId: "assistant-123",
|
|
184
|
+
runtimeUrl: "http://runtime.example",
|
|
185
|
+
localUrl: "http://127.0.0.1:3000",
|
|
186
|
+
cloud: "local",
|
|
187
|
+
});
|
|
188
|
+
saveGuardianToken("assistant-123", guardianTokenFixture());
|
|
189
|
+
|
|
190
|
+
await setup();
|
|
191
|
+
|
|
192
|
+
expect(fetchCalls[0].url).toBe("http://127.0.0.1:3000/v1/secrets/read");
|
|
193
|
+
expect(fetchCalls[0].headers.get("Authorization")).toBe(
|
|
194
|
+
"Bearer guardian-token",
|
|
195
|
+
);
|
|
196
|
+
expect(secretWriteCall().body).toEqual({
|
|
197
|
+
type: "api_key",
|
|
198
|
+
name: "anthropic",
|
|
199
|
+
value: "test-anthropic-key",
|
|
200
|
+
});
|
|
201
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
202
|
+
"Anthropic API key saved to assistant from the environment.",
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("honors an explicit provider option", async () => {
|
|
207
|
+
process.argv = ["bun", "vellum", "setup", "--provider", "openai"];
|
|
208
|
+
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
209
|
+
writeLockfile({
|
|
210
|
+
assistantId: "assistant-123",
|
|
211
|
+
runtimeUrl: "http://127.0.0.1:3000",
|
|
212
|
+
cloud: "local",
|
|
213
|
+
});
|
|
214
|
+
saveGuardianToken("assistant-123", guardianTokenFixture());
|
|
215
|
+
|
|
216
|
+
await setup();
|
|
217
|
+
|
|
218
|
+
expect(secretWriteCall().body).toEqual({
|
|
219
|
+
type: "api_key",
|
|
220
|
+
name: "openai",
|
|
221
|
+
value: "test-openai-key",
|
|
222
|
+
});
|
|
223
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
224
|
+
"OpenAI API key saved to assistant from the environment.",
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("leases a guardian token for local assistants when no token exists", async () => {
|
|
229
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
230
|
+
writeLockfile({
|
|
231
|
+
assistantId: "assistant-123",
|
|
232
|
+
runtimeUrl: "http://runtime.example",
|
|
233
|
+
localUrl: "http://127.0.0.1:3000",
|
|
234
|
+
cloud: "local",
|
|
235
|
+
});
|
|
236
|
+
installFetchStub({
|
|
237
|
+
leasedToken: guardianTokenFixture({
|
|
238
|
+
accessToken: "leased-guardian-token",
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await setup();
|
|
243
|
+
|
|
244
|
+
expect(fetchCalls[0].url).toBe("http://127.0.0.1:3000/v1/guardian/init");
|
|
245
|
+
expect(fetchCalls[1].url).toBe("http://127.0.0.1:3000/v1/secrets/read");
|
|
246
|
+
expect(fetchCalls[1].headers.get("Authorization")).toBe(
|
|
247
|
+
"Bearer leased-guardian-token",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("uses saved bootstrap secret when leasing a Docker guardian token", async () => {
|
|
252
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
253
|
+
writeLockfile({
|
|
254
|
+
assistantId: "assistant-123",
|
|
255
|
+
runtimeUrl: "http://localhost:7831",
|
|
256
|
+
cloud: "docker",
|
|
257
|
+
guardianBootstrapSecret: "test-bootstrap-secret",
|
|
258
|
+
});
|
|
259
|
+
installFetchStub({
|
|
260
|
+
leasedToken: guardianTokenFixture({
|
|
261
|
+
accessToken: "leased-docker-token",
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await setup();
|
|
266
|
+
|
|
267
|
+
expect(fetchCalls[0].url).toBe("http://localhost:7831/v1/guardian/init");
|
|
268
|
+
expect(fetchCalls[0].headers.get("x-bootstrap-secret")).toBe(
|
|
269
|
+
"test-bootstrap-secret",
|
|
270
|
+
);
|
|
271
|
+
expect(fetchCalls[1].headers.get("Authorization")).toBe(
|
|
272
|
+
"Bearer leased-docker-token",
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("falls back to runtime URL and lockfile bearer token", async () => {
|
|
277
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
278
|
+
writeLockfile({
|
|
279
|
+
assistantId: "assistant-123",
|
|
280
|
+
runtimeUrl: "https://assistant.example",
|
|
281
|
+
bearerToken: "entry-token",
|
|
282
|
+
cloud: "vellum",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await setup();
|
|
286
|
+
|
|
287
|
+
expect(fetchCalls[0].url).toBe("https://assistant.example/v1/secrets/read");
|
|
288
|
+
expect(fetchCalls[0].headers.get("Authorization")).toBe(
|
|
289
|
+
"Bearer entry-token",
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("falls back to the lockfile bearer token when guardian token is expired", async () => {
|
|
294
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
295
|
+
writeLockfile({
|
|
296
|
+
assistantId: "assistant-123",
|
|
297
|
+
runtimeUrl: "https://assistant.example",
|
|
298
|
+
bearerToken: "entry-token",
|
|
299
|
+
cloud: "vellum",
|
|
300
|
+
});
|
|
301
|
+
saveGuardianToken(
|
|
302
|
+
"assistant-123",
|
|
303
|
+
guardianTokenFixture({
|
|
304
|
+
accessToken: "expired-guardian-token",
|
|
305
|
+
accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
await setup();
|
|
310
|
+
|
|
311
|
+
expect(fetchCalls[0].url).toBe(
|
|
312
|
+
"https://assistant.example/v1/guardian/refresh",
|
|
313
|
+
);
|
|
314
|
+
expect(fetchCalls[0].headers.get("Authorization")).toBe(
|
|
315
|
+
"Bearer expired-guardian-token",
|
|
316
|
+
);
|
|
317
|
+
expect(fetchCalls[1].headers.get("Authorization")).toBe(
|
|
318
|
+
"Bearer entry-token",
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("uses a refreshed guardian token before lockfile fallback", async () => {
|
|
323
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
324
|
+
writeLockfile({
|
|
325
|
+
assistantId: "assistant-123",
|
|
326
|
+
runtimeUrl: "https://assistant.example",
|
|
327
|
+
bearerToken: "entry-token",
|
|
328
|
+
cloud: "vellum",
|
|
329
|
+
});
|
|
330
|
+
saveGuardianToken(
|
|
331
|
+
"assistant-123",
|
|
332
|
+
guardianTokenFixture({
|
|
333
|
+
accessToken: "expired-guardian-token",
|
|
334
|
+
accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
installFetchStub({
|
|
338
|
+
refreshedToken: guardianTokenFixture({
|
|
339
|
+
accessToken: "fresh-guardian-token",
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await setup();
|
|
344
|
+
|
|
345
|
+
expect(fetchCalls[0].url).toBe(
|
|
346
|
+
"https://assistant.example/v1/guardian/refresh",
|
|
347
|
+
);
|
|
348
|
+
expect(fetchCalls[1].headers.get("Authorization")).toBe(
|
|
349
|
+
"Bearer fresh-guardian-token",
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
function setOptionalEnv(name: string, value: string | undefined): void {
|
|
355
|
+
if (value === undefined) {
|
|
356
|
+
delete process.env[name];
|
|
357
|
+
} else {
|
|
358
|
+
process.env[name] = value;
|
|
359
|
+
}
|
|
360
|
+
}
|