@vellumai/cli 0.8.0 → 0.8.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/package.json +1 -1
- package/src/__tests__/orphan-detection.test.ts +287 -0
- package/src/__tests__/ps-platform-status.test.ts +182 -0
- package/src/__tests__/search-provider-env-var-parity.test.ts +48 -0
- package/src/__tests__/sync-events.test.ts +54 -0
- package/src/commands/client.ts +71 -9
- package/src/commands/events.ts +13 -1
- package/src/commands/login.ts +3 -2
- package/src/commands/ps.ts +28 -17
- package/src/components/DefaultMainScreen.tsx +8 -9
- package/src/lib/__tests__/docker.test.ts +11 -0
- package/src/lib/assistant-config.ts +65 -0
- package/src/lib/client-identity.ts +1 -0
- package/src/lib/orphan-detection.ts +66 -1
- package/src/lib/platform-client.ts +8 -7
- package/src/lib/statefulset.ts +12 -0
- package/src/lib/sync-cloud-assistants.ts +16 -9
- package/src/lib/upgrade-lifecycle.ts +9 -73
- package/src/shared/provider-env-vars.ts +12 -8
package/package.json
CHANGED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Point lockfile operations at a temp directory before importing anything that
|
|
7
|
+
// would otherwise resolve real on-host paths.
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-orphan-detection-test-"));
|
|
9
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
detectOrphanedProcesses,
|
|
13
|
+
getKnownPidsFromAssistants,
|
|
14
|
+
} from "../lib/orphan-detection.js";
|
|
15
|
+
import {
|
|
16
|
+
loadAllAssistantsAcrossEnvs,
|
|
17
|
+
type AssistantEntry,
|
|
18
|
+
} from "../lib/assistant-config.js";
|
|
19
|
+
import type { EnvironmentDefinition } from "../lib/environments/types.js";
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
23
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function makeLocalEntry(
|
|
27
|
+
id: string,
|
|
28
|
+
instanceDir: string,
|
|
29
|
+
pids: {
|
|
30
|
+
daemon?: string;
|
|
31
|
+
gateway?: string;
|
|
32
|
+
qdrant?: string;
|
|
33
|
+
embed?: string;
|
|
34
|
+
} = {},
|
|
35
|
+
): AssistantEntry {
|
|
36
|
+
const vellumDir = join(instanceDir, ".vellum");
|
|
37
|
+
mkdirSync(join(vellumDir, "workspace", "data", "qdrant"), {
|
|
38
|
+
recursive: true,
|
|
39
|
+
});
|
|
40
|
+
if (pids.daemon !== undefined) {
|
|
41
|
+
writeFileSync(join(vellumDir, "workspace", "vellum.pid"), pids.daemon);
|
|
42
|
+
}
|
|
43
|
+
if (pids.gateway !== undefined) {
|
|
44
|
+
writeFileSync(join(vellumDir, "gateway.pid"), pids.gateway);
|
|
45
|
+
}
|
|
46
|
+
if (pids.qdrant !== undefined) {
|
|
47
|
+
writeFileSync(
|
|
48
|
+
join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"),
|
|
49
|
+
pids.qdrant,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (pids.embed !== undefined) {
|
|
53
|
+
writeFileSync(join(vellumDir, "workspace", "embed-worker.pid"), pids.embed);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
assistantId: id,
|
|
57
|
+
runtimeUrl: "http://localhost:7821",
|
|
58
|
+
cloud: "local",
|
|
59
|
+
resources: {
|
|
60
|
+
instanceDir,
|
|
61
|
+
daemonPort: 7821,
|
|
62
|
+
gatewayPort: 7830,
|
|
63
|
+
qdrantPort: 6333,
|
|
64
|
+
cesPort: 8090,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("getKnownPidsFromAssistants", () => {
|
|
70
|
+
let perTestDir: string;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
perTestDir = mkdtempSync(join(testDir, "case-"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("collects daemon, gateway, qdrant, and embed-worker PIDs", () => {
|
|
77
|
+
const entry = makeLocalEntry(
|
|
78
|
+
"alpha",
|
|
79
|
+
join(perTestDir, "alpha"),
|
|
80
|
+
{ daemon: "100", gateway: "200", qdrant: "300", embed: "400" },
|
|
81
|
+
);
|
|
82
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
83
|
+
expect(pids).toEqual(new Set(["100", "200", "300", "400"]));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("skips missing PID files without throwing", () => {
|
|
87
|
+
const entry = makeLocalEntry("beta", join(perTestDir, "beta"), {
|
|
88
|
+
daemon: "100",
|
|
89
|
+
});
|
|
90
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
91
|
+
expect(pids).toEqual(new Set(["100"]));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("includes docker watcherPid when present", () => {
|
|
95
|
+
const entry: AssistantEntry = {
|
|
96
|
+
assistantId: "docker-1",
|
|
97
|
+
runtimeUrl: "http://localhost:18100",
|
|
98
|
+
cloud: "docker",
|
|
99
|
+
watcherPid: 555,
|
|
100
|
+
};
|
|
101
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
102
|
+
expect(pids).toEqual(new Set(["555"]));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("ignores non-local entries without watcherPid", () => {
|
|
106
|
+
const entry: AssistantEntry = {
|
|
107
|
+
assistantId: "managed-1",
|
|
108
|
+
runtimeUrl: "https://platform.vellum.ai/foo",
|
|
109
|
+
cloud: "vellum",
|
|
110
|
+
};
|
|
111
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
112
|
+
expect(pids.size).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("local entry without resources contributes no PIDs", () => {
|
|
116
|
+
const entry: AssistantEntry = {
|
|
117
|
+
assistantId: "legacy",
|
|
118
|
+
runtimeUrl: "http://localhost:7821",
|
|
119
|
+
cloud: "local",
|
|
120
|
+
};
|
|
121
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
122
|
+
expect(pids.size).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("aggregates PIDs across multiple assistants", () => {
|
|
126
|
+
const a = makeLocalEntry("a", join(perTestDir, "a"), {
|
|
127
|
+
daemon: "100",
|
|
128
|
+
gateway: "200",
|
|
129
|
+
});
|
|
130
|
+
const b = makeLocalEntry("b", join(perTestDir, "b"), {
|
|
131
|
+
daemon: "101",
|
|
132
|
+
gateway: "201",
|
|
133
|
+
});
|
|
134
|
+
const pids = getKnownPidsFromAssistants([a, b]);
|
|
135
|
+
expect(pids).toEqual(new Set(["100", "200", "101", "201"]));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("loadAllAssistantsAcrossEnvs", () => {
|
|
140
|
+
function makeEnv(name: string, lockfileDir: string): EnvironmentDefinition {
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
platformUrl: "https://example.invalid",
|
|
144
|
+
webUrl: "https://example.invalid",
|
|
145
|
+
lockfileDirOverride: lockfileDir,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test("aggregates entries from every provided environment's lockfile", () => {
|
|
150
|
+
const envADir = mkdtempSync(join(testDir, "envA-"));
|
|
151
|
+
const envBDir = mkdtempSync(join(testDir, "envB-"));
|
|
152
|
+
|
|
153
|
+
writeFileSync(
|
|
154
|
+
join(envADir, "lockfile.json"),
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
assistants: [
|
|
157
|
+
{
|
|
158
|
+
assistantId: "alpha",
|
|
159
|
+
runtimeUrl: "http://localhost:7821",
|
|
160
|
+
cloud: "local",
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(envBDir, "lockfile.json"),
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
assistants: [
|
|
169
|
+
{
|
|
170
|
+
assistantId: "beta",
|
|
171
|
+
runtimeUrl: "http://localhost:18100",
|
|
172
|
+
cloud: "docker",
|
|
173
|
+
watcherPid: 777,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const all = loadAllAssistantsAcrossEnvs([
|
|
180
|
+
makeEnv("envA", envADir),
|
|
181
|
+
makeEnv("envB", envBDir),
|
|
182
|
+
]);
|
|
183
|
+
const ids = all.map((e) => e.assistantId).sort();
|
|
184
|
+
expect(ids).toEqual(["alpha", "beta"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("returns empty list when no envs have lockfiles", () => {
|
|
188
|
+
const envDir = mkdtempSync(join(testDir, "empty-"));
|
|
189
|
+
const all = loadAllAssistantsAcrossEnvs([makeEnv("missing", envDir)]);
|
|
190
|
+
expect(all).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("skips malformed JSON without throwing", () => {
|
|
194
|
+
const envDir = mkdtempSync(join(testDir, "malformed-"));
|
|
195
|
+
writeFileSync(join(envDir, "lockfile.json"), "{not json");
|
|
196
|
+
const all = loadAllAssistantsAcrossEnvs([makeEnv("bad", envDir)]);
|
|
197
|
+
expect(all).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("skips entries missing required fields", () => {
|
|
201
|
+
const envDir = mkdtempSync(join(testDir, "partial-"));
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(envDir, "lockfile.json"),
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
assistants: [
|
|
206
|
+
{ assistantId: "no-url" }, // missing runtimeUrl
|
|
207
|
+
{ runtimeUrl: "http://x" }, // missing assistantId
|
|
208
|
+
{
|
|
209
|
+
assistantId: "good",
|
|
210
|
+
runtimeUrl: "http://localhost:7821",
|
|
211
|
+
cloud: "local",
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
const all = loadAllAssistantsAcrossEnvs([makeEnv("partial", envDir)]);
|
|
217
|
+
expect(all.map((e) => e.assistantId)).toEqual(["good"]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("end-to-end: dev env's daemon is not flagged as orphan from local env", () => {
|
|
221
|
+
// Simulate Vargas's reported bug: `local` env has no assistants, but a
|
|
222
|
+
// `dev` env assistant is running with a recorded daemon PID. The orphan
|
|
223
|
+
// filter must treat that PID as known.
|
|
224
|
+
const devDir = mkdtempSync(join(testDir, "dev-"));
|
|
225
|
+
const instanceDir = join(devDir, "instances", "quiet-finch");
|
|
226
|
+
makeLocalEntry("quiet-finch", instanceDir, {
|
|
227
|
+
daemon: "19067",
|
|
228
|
+
gateway: "19087",
|
|
229
|
+
qdrant: "19167",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
writeFileSync(
|
|
233
|
+
join(devDir, "lockfile.json"),
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
assistants: [
|
|
236
|
+
{
|
|
237
|
+
assistantId: "quiet-finch",
|
|
238
|
+
runtimeUrl: "http://127.0.0.1:18100",
|
|
239
|
+
cloud: "local",
|
|
240
|
+
resources: {
|
|
241
|
+
instanceDir,
|
|
242
|
+
daemonPort: 18000,
|
|
243
|
+
gatewayPort: 18100,
|
|
244
|
+
qdrantPort: 18200,
|
|
245
|
+
cesPort: 18300,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const devEntries = loadAllAssistantsAcrossEnvs([makeEnv("dev", devDir)]);
|
|
253
|
+
expect(devEntries).toHaveLength(1);
|
|
254
|
+
|
|
255
|
+
const knownPids = getKnownPidsFromAssistants(devEntries);
|
|
256
|
+
expect(knownPids).toEqual(new Set(["19067", "19087", "19167"]));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("detectOrphanedProcesses", () => {
|
|
261
|
+
test("excludes PIDs passed via excludePids", async () => {
|
|
262
|
+
// The orphan detector calls `ps ax` and filters by regex. The process
|
|
263
|
+
// running this test (bun) is itself a node-family process whose pid will
|
|
264
|
+
// not match the vellum/qdrant/openclaw regex, so the natural result of
|
|
265
|
+
// the scan is "no rows". To assert exclusion semantics deterministically,
|
|
266
|
+
// we just confirm the function accepts an excludePids option and returns
|
|
267
|
+
// an array — the meaningful behavior assertion lives in the integration
|
|
268
|
+
// path (the function's `knownPids.has(p.pid)` short-circuit), which we
|
|
269
|
+
// exercise indirectly by passing our own PID (guaranteed to never be
|
|
270
|
+
// double-counted).
|
|
271
|
+
const ownPid = String(process.pid);
|
|
272
|
+
const result = await detectOrphanedProcesses({
|
|
273
|
+
excludePids: new Set([ownPid]),
|
|
274
|
+
});
|
|
275
|
+
expect(Array.isArray(result)).toBe(true);
|
|
276
|
+
for (const orphan of result) {
|
|
277
|
+
expect(orphan.pid).not.toBe(ownPid);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("returns an array (smoke)", async () => {
|
|
282
|
+
const result = await detectOrphanedProcesses({
|
|
283
|
+
excludePids: new Set(),
|
|
284
|
+
});
|
|
285
|
+
expect(Array.isArray(result)).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Temp directory for lockfile isolation
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-ps-platform-status-test-"));
|
|
11
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Mocks — set up before importing the command under test. All spies are
|
|
15
|
+
// restored in afterAll so we don't leak module state to neighbouring suites.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
import * as assistantConfig from "../lib/assistant-config.js";
|
|
19
|
+
import * as orphanDetection from "../lib/orphan-detection.js";
|
|
20
|
+
import * as platformClient from "../lib/platform-client.js";
|
|
21
|
+
|
|
22
|
+
const loadAllAssistantsMock = spyOn(
|
|
23
|
+
assistantConfig,
|
|
24
|
+
"loadAllAssistants",
|
|
25
|
+
).mockReturnValue([]);
|
|
26
|
+
const getActiveAssistantMock = spyOn(
|
|
27
|
+
assistantConfig,
|
|
28
|
+
"getActiveAssistant",
|
|
29
|
+
).mockReturnValue(null);
|
|
30
|
+
const detectOrphanedProcessesMock = spyOn(
|
|
31
|
+
orphanDetection,
|
|
32
|
+
"detectOrphanedProcesses",
|
|
33
|
+
).mockResolvedValue([]);
|
|
34
|
+
const getPlatformUrlMock = spyOn(
|
|
35
|
+
platformClient,
|
|
36
|
+
"getPlatformUrl",
|
|
37
|
+
).mockReturnValue("http://platform.test");
|
|
38
|
+
|
|
39
|
+
// Per-test toggle for `readPlatformToken`.
|
|
40
|
+
const readPlatformTokenMock = spyOn(
|
|
41
|
+
platformClient,
|
|
42
|
+
"readPlatformToken",
|
|
43
|
+
).mockReturnValue(null);
|
|
44
|
+
|
|
45
|
+
// `fetchCurrentUser` + `fetchPlatformAssistants` are spied so we can assert
|
|
46
|
+
// they're never invoked on the no-token path, and re-shaped per-test for the
|
|
47
|
+
// token-but-unreachable path.
|
|
48
|
+
const fetchCurrentUserMock = spyOn(
|
|
49
|
+
platformClient,
|
|
50
|
+
"fetchCurrentUser",
|
|
51
|
+
).mockResolvedValue({
|
|
52
|
+
id: "u1",
|
|
53
|
+
email: "test@example.com",
|
|
54
|
+
display: "Test",
|
|
55
|
+
});
|
|
56
|
+
const fetchPlatformAssistantsMock = spyOn(
|
|
57
|
+
platformClient,
|
|
58
|
+
"fetchPlatformAssistants",
|
|
59
|
+
).mockResolvedValue([]);
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// stdout / stderr capture
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
let stdout: string[];
|
|
66
|
+
let stderr: string[];
|
|
67
|
+
let originalLog: typeof console.log;
|
|
68
|
+
let originalError: typeof console.error;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
stdout = [];
|
|
72
|
+
stderr = [];
|
|
73
|
+
originalLog = console.log;
|
|
74
|
+
originalError = console.error;
|
|
75
|
+
console.log = ((...args: unknown[]) => {
|
|
76
|
+
stdout.push(args.map((a) => String(a)).join(" "));
|
|
77
|
+
}) as typeof console.log;
|
|
78
|
+
console.error = ((...args: unknown[]) => {
|
|
79
|
+
stderr.push(args.map((a) => String(a)).join(" "));
|
|
80
|
+
}) as typeof console.error;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
console.log = originalLog;
|
|
85
|
+
console.error = originalError;
|
|
86
|
+
readPlatformTokenMock.mockReturnValue(null);
|
|
87
|
+
fetchCurrentUserMock.mockReset();
|
|
88
|
+
fetchCurrentUserMock.mockResolvedValue({
|
|
89
|
+
id: "u1",
|
|
90
|
+
email: "test@example.com",
|
|
91
|
+
display: "Test",
|
|
92
|
+
});
|
|
93
|
+
fetchPlatformAssistantsMock.mockReset();
|
|
94
|
+
fetchPlatformAssistantsMock.mockResolvedValue([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterAll(() => {
|
|
98
|
+
loadAllAssistantsMock.mockRestore();
|
|
99
|
+
getActiveAssistantMock.mockRestore();
|
|
100
|
+
detectOrphanedProcessesMock.mockRestore();
|
|
101
|
+
getPlatformUrlMock.mockRestore();
|
|
102
|
+
readPlatformTokenMock.mockRestore();
|
|
103
|
+
fetchCurrentUserMock.mockRestore();
|
|
104
|
+
fetchPlatformAssistantsMock.mockRestore();
|
|
105
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Import the command under test AFTER mocks are wired up
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
import { listAllAssistants } from "../commands/ps.js";
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Tests
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe("vellum ps — platform status line", () => {
|
|
119
|
+
test("no local token: prints 'Platform: not logged in' and skips ALL network fetches", async () => {
|
|
120
|
+
readPlatformTokenMock.mockReturnValue(null);
|
|
121
|
+
|
|
122
|
+
await listAllAssistants(false);
|
|
123
|
+
|
|
124
|
+
// The status line is present, exactly once, with no redundant error log.
|
|
125
|
+
expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
|
|
126
|
+
"Platform: not logged in",
|
|
127
|
+
]);
|
|
128
|
+
expect(
|
|
129
|
+
stderr.some((l) => l.includes("Failed to fetch organization")),
|
|
130
|
+
).toBe(false);
|
|
131
|
+
expect(
|
|
132
|
+
stdout.some((l) => l.includes("Failed to fetch organization")),
|
|
133
|
+
).toBe(false);
|
|
134
|
+
|
|
135
|
+
// Structural guarantee: we never even tried to talk to the platform.
|
|
136
|
+
expect(fetchCurrentUserMock).not.toHaveBeenCalled();
|
|
137
|
+
expect(fetchPlatformAssistantsMock).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("local token present but platform is unreachable: still shows 'Platform: not logged in' with no leaked org-fetch error", async () => {
|
|
141
|
+
readPlatformTokenMock.mockReturnValue("session_abc123");
|
|
142
|
+
// Simulate the exact Bun connect failure the user reported:
|
|
143
|
+
// "Unable to connect. Is the computer able to access the url?"
|
|
144
|
+
const connectError = new Error(
|
|
145
|
+
"Unable to connect. Is the computer able to access the url?",
|
|
146
|
+
);
|
|
147
|
+
fetchCurrentUserMock.mockRejectedValue(connectError);
|
|
148
|
+
fetchPlatformAssistantsMock.mockRejectedValue(connectError);
|
|
149
|
+
|
|
150
|
+
await listAllAssistants(false);
|
|
151
|
+
|
|
152
|
+
expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
|
|
153
|
+
"Platform: not logged in",
|
|
154
|
+
]);
|
|
155
|
+
expect(
|
|
156
|
+
stderr.some((l) => l.includes("Failed to fetch organization")),
|
|
157
|
+
).toBe(false);
|
|
158
|
+
expect(
|
|
159
|
+
stdout.some((l) => l.includes("Failed to fetch organization")),
|
|
160
|
+
).toBe(false);
|
|
161
|
+
expect(
|
|
162
|
+
stderr.some((l) => l.includes("Unable to connect")),
|
|
163
|
+
).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("local token present and platform reachable: prints 'Platform: logged in as <email>'", async () => {
|
|
167
|
+
readPlatformTokenMock.mockReturnValue("session_abc123");
|
|
168
|
+
fetchCurrentUserMock.mockResolvedValue({
|
|
169
|
+
id: "u1",
|
|
170
|
+
email: "vargas@vellum.ai",
|
|
171
|
+
display: "Vargas",
|
|
172
|
+
});
|
|
173
|
+
fetchPlatformAssistantsMock.mockResolvedValue([]);
|
|
174
|
+
|
|
175
|
+
await listAllAssistants(false);
|
|
176
|
+
|
|
177
|
+
expect(stdout).toContain("Platform: logged in as vargas@vellum.ai");
|
|
178
|
+
expect(
|
|
179
|
+
stderr.some((l) => l.includes("Failed to fetch organization")),
|
|
180
|
+
).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
|
|
5
|
+
import { SEARCH_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Drift guard for the CLI-side search provider env-var mirror.
|
|
9
|
+
*
|
|
10
|
+
* `cli/src/shared/provider-env-vars.ts` hardcodes the search env-var names so
|
|
11
|
+
* the CLI doesn't need to import the assistant's
|
|
12
|
+
* `SEARCH_PROVIDER_CATALOG` (no CLI → assistant cross-package imports exist).
|
|
13
|
+
* This test pulls the catalog JSON at `meta/web-search-provider-catalog.json`
|
|
14
|
+
* — which is kept in sync with `SEARCH_PROVIDER_CATALOG` by
|
|
15
|
+
* `assistant/src/__tests__/web-search-catalog-parity.test.ts` — and asserts
|
|
16
|
+
* the CLI's mirror matches the catalog's `envVar` entries.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors `llm-provider-env-var-parity.test.ts`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
|
|
22
|
+
|
|
23
|
+
interface SearchCatalogEntry {
|
|
24
|
+
id: string;
|
|
25
|
+
kind: "managed" | "byok";
|
|
26
|
+
envVar?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SearchCatalog {
|
|
30
|
+
version: number;
|
|
31
|
+
providers: SearchCatalogEntry[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadSearchCatalog(): SearchCatalog {
|
|
35
|
+
const path = join(REPO_ROOT, "meta", "web-search-provider-catalog.json");
|
|
36
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("CLI search provider env-var parity", () => {
|
|
40
|
+
test("SEARCH_PROVIDER_ENV_VAR_NAMES matches meta/web-search-provider-catalog.json entries with envVar", () => {
|
|
41
|
+
const catalog = loadSearchCatalog();
|
|
42
|
+
const expected: Record<string, string> = {};
|
|
43
|
+
for (const provider of catalog.providers) {
|
|
44
|
+
if (provider.envVar) expected[provider.id] = provider.envVar;
|
|
45
|
+
}
|
|
46
|
+
expect(SEARCH_PROVIDER_ENV_VAR_NAMES).toEqual(expected);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { renderMarkdown } from "../commands/events.js";
|
|
4
|
+
|
|
5
|
+
type AssistantEvent = Parameters<typeof renderMarkdown>[0];
|
|
6
|
+
|
|
7
|
+
function makeEvent(message: AssistantEvent["message"]): AssistantEvent {
|
|
8
|
+
return {
|
|
9
|
+
id: "event-123",
|
|
10
|
+
assistantId: "assistant-123",
|
|
11
|
+
emittedAt: "2026-01-01T00:00:00.000Z",
|
|
12
|
+
message,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("sync_changed events", () => {
|
|
17
|
+
test("renders sync tags clearly in vellum events markdown output", () => {
|
|
18
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
19
|
+
try {
|
|
20
|
+
renderMarkdown(
|
|
21
|
+
makeEvent({
|
|
22
|
+
type: "sync_changed",
|
|
23
|
+
tags: ["assistant:self:avatar", "conversations:list"],
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(consoleLog).toHaveBeenCalledWith(
|
|
28
|
+
"\n> **Sync changed:** `assistant:self:avatar`, `conversations:list`",
|
|
29
|
+
);
|
|
30
|
+
} finally {
|
|
31
|
+
consoleLog.mockRestore();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("tolerates malformed sync tags without throwing", () => {
|
|
36
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
37
|
+
try {
|
|
38
|
+
expect(() =>
|
|
39
|
+
renderMarkdown(
|
|
40
|
+
makeEvent({
|
|
41
|
+
type: "sync_changed",
|
|
42
|
+
tags: ["assistant:self:avatar", 42, null],
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
).not.toThrow();
|
|
46
|
+
|
|
47
|
+
expect(consoleLog).toHaveBeenCalledWith(
|
|
48
|
+
"\n> **Sync changed:** `assistant:self:avatar`",
|
|
49
|
+
);
|
|
50
|
+
} finally {
|
|
51
|
+
consoleLog.mockRestore();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/commands/client.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
|
|
3
5
|
import {
|
|
4
6
|
findAssistantByName,
|
|
@@ -14,6 +16,7 @@ import { loadGuardianToken } from "../lib/guardian-token";
|
|
|
14
16
|
import { getLocalLanIPv4 } from "../lib/local";
|
|
15
17
|
import {
|
|
16
18
|
CLI_INTERFACE_ID,
|
|
19
|
+
WEB_INTERFACE_ID,
|
|
17
20
|
getClientRegistrationHeaders,
|
|
18
21
|
} from "../lib/client-identity";
|
|
19
22
|
import {
|
|
@@ -22,7 +25,7 @@ import {
|
|
|
22
25
|
} from "../lib/platform-client";
|
|
23
26
|
import { tuiLog } from "../lib/tui-log";
|
|
24
27
|
|
|
25
|
-
const SUPPORTED_INTERFACES = ["cli"] as const;
|
|
28
|
+
const SUPPORTED_INTERFACES = ["cli", "web"] as const;
|
|
26
29
|
type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
|
|
27
30
|
|
|
28
31
|
const ANSI = {
|
|
@@ -133,12 +136,6 @@ function parseArgs(): ParsedArgs {
|
|
|
133
136
|
assistantId = flagArgs[++i];
|
|
134
137
|
} else if ((flag === "--interface" || flag === "-i") && flagArgs[i + 1]) {
|
|
135
138
|
const value = flagArgs[++i];
|
|
136
|
-
if (value === "web") {
|
|
137
|
-
console.error(
|
|
138
|
-
`--interface web is not yet supported. Coming soon.`,
|
|
139
|
-
);
|
|
140
|
-
process.exit(1);
|
|
141
|
-
}
|
|
142
139
|
if (!(SUPPORTED_INTERFACES as readonly string[]).includes(value)) {
|
|
143
140
|
console.error(
|
|
144
141
|
`Unknown interface '${value}'. Supported: ${SUPPORTED_INTERFACES.join(", ")}.`,
|
|
@@ -213,7 +210,7 @@ ${ANSI.bold}ARGUMENTS:${ANSI.reset}
|
|
|
213
210
|
${ANSI.bold}OPTIONS:${ANSI.reset}
|
|
214
211
|
-u, --url <url> Runtime URL
|
|
215
212
|
-a, --assistant-id <id> Assistant ID
|
|
216
|
-
-i, --interface <id> Interface identifier (default
|
|
213
|
+
-i, --interface <id> Interface identifier: cli (default) or web
|
|
217
214
|
-h, --help Show this help message
|
|
218
215
|
|
|
219
216
|
${ANSI.bold}DEFAULTS:${ANSI.reset}
|
|
@@ -228,6 +225,66 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
|
|
|
228
225
|
`);
|
|
229
226
|
}
|
|
230
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Walk up from this file's location to find a sibling `clients/web` package.
|
|
230
|
+
*
|
|
231
|
+
* Returns the absolute path to its directory, or null when not found —
|
|
232
|
+
* e.g. when the CLI is installed via npm/bunx, where the `clients/web`
|
|
233
|
+
* source isn't shipped alongside `@vellumai/cli`. For now we treat the
|
|
234
|
+
* `--interface web` path as source-checkout-only.
|
|
235
|
+
*/
|
|
236
|
+
function findClientsWebDir(): string | null {
|
|
237
|
+
let dir = import.meta.dir;
|
|
238
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
239
|
+
const candidate = path.join(dir, "clients", "web", "package.json");
|
|
240
|
+
if (existsSync(candidate)) {
|
|
241
|
+
return path.dirname(candidate);
|
|
242
|
+
}
|
|
243
|
+
const parent = path.dirname(dir);
|
|
244
|
+
if (parent === dir) break;
|
|
245
|
+
dir = parent;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Spawn the `clients/web` package's `local` script and proxy its lifecycle.
|
|
252
|
+
*
|
|
253
|
+
* The web client is deliberately not declared as a dependency of `@vellumai/cli`:
|
|
254
|
+
* the CLI is published, the web package is not. Locating it on disk and
|
|
255
|
+
* shelling out keeps the two packages independent.
|
|
256
|
+
*/
|
|
257
|
+
async function runWebInterface(): Promise<void> {
|
|
258
|
+
const webDir = findClientsWebDir();
|
|
259
|
+
if (!webDir) {
|
|
260
|
+
console.error(
|
|
261
|
+
`${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
|
|
262
|
+
`clients/web. This interface currently requires running ` +
|
|
263
|
+
`vellum from a source checkout of vellum-assistant.`,
|
|
264
|
+
);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const child = Bun.spawn({
|
|
269
|
+
cmd: ["bun", "run", "local"],
|
|
270
|
+
cwd: webDir,
|
|
271
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const forward = (signal: "SIGINT" | "SIGTERM"): void => {
|
|
275
|
+
try {
|
|
276
|
+
child.kill(signal);
|
|
277
|
+
} catch {
|
|
278
|
+
// Child already exited; nothing to forward.
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
process.on("SIGINT", () => forward("SIGINT"));
|
|
282
|
+
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
283
|
+
|
|
284
|
+
const exitCode = await child.exited;
|
|
285
|
+
process.exit(typeof exitCode === "number" ? exitCode : 0);
|
|
286
|
+
}
|
|
287
|
+
|
|
231
288
|
export async function client(): Promise<void> {
|
|
232
289
|
const {
|
|
233
290
|
runtimeUrl,
|
|
@@ -241,6 +298,11 @@ export async function client(): Promise<void> {
|
|
|
241
298
|
zone,
|
|
242
299
|
} = parseArgs();
|
|
243
300
|
|
|
301
|
+
if (interfaceId === WEB_INTERFACE_ID) {
|
|
302
|
+
await runWebInterface();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
244
306
|
tuiLog.init();
|
|
245
307
|
tuiLog.info("session start", {
|
|
246
308
|
runtimeUrl,
|
package/src/commands/events.ts
CHANGED
|
@@ -49,13 +49,14 @@ interface AssistantEvent {
|
|
|
49
49
|
content?: string;
|
|
50
50
|
message?: string;
|
|
51
51
|
chunk?: string;
|
|
52
|
+
tags?: unknown;
|
|
52
53
|
conversationId?: string;
|
|
53
54
|
[key: string]: unknown;
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/** Render an event as human-readable markdown to stdout. */
|
|
58
|
-
function renderMarkdown(event: AssistantEvent): void {
|
|
59
|
+
export function renderMarkdown(event: AssistantEvent): void {
|
|
59
60
|
const msg = event.message;
|
|
60
61
|
switch (msg.type) {
|
|
61
62
|
case "assistant_text_delta":
|
|
@@ -94,6 +95,17 @@ function renderMarkdown(event: AssistantEvent): void {
|
|
|
94
95
|
case "user_message_echo":
|
|
95
96
|
console.log(`\n**You:** ${msg.text}`);
|
|
96
97
|
break;
|
|
98
|
+
case "sync_changed": {
|
|
99
|
+
const tags = Array.isArray(msg.tags)
|
|
100
|
+
? msg.tags.filter((tag): tag is string => typeof tag === "string")
|
|
101
|
+
: [];
|
|
102
|
+
const renderedTags =
|
|
103
|
+
tags.length > 0
|
|
104
|
+
? tags.map((tag) => `\`${tag}\``).join(", ")
|
|
105
|
+
: "(no tags)";
|
|
106
|
+
console.log(`\n> **Sync changed:** ${renderedTags}`);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
97
109
|
default:
|
|
98
110
|
// Silently skip events that don't have a markdown representation
|
|
99
111
|
// (e.g. heartbeat comments, activity states, etc.)
|
package/src/commands/login.ts
CHANGED
|
@@ -287,9 +287,10 @@ export async function login(): Promise<void> {
|
|
|
287
287
|
|
|
288
288
|
// Sync cloud assistants from the platform into the local lockfile.
|
|
289
289
|
// This ensures `vellum ps` shows managed assistants immediately
|
|
290
|
-
// after login (e.g. after a retire-and-rehatch cycle).
|
|
290
|
+
// after login (e.g. after a retire-and-rehatch cycle). We've just
|
|
291
|
+
// saved this token, so it's guaranteed non-empty here.
|
|
291
292
|
try {
|
|
292
|
-
const result = await syncCloudAssistants();
|
|
293
|
+
const result = await syncCloudAssistants(token);
|
|
293
294
|
if (result) {
|
|
294
295
|
const total = result.added + result.removed;
|
|
295
296
|
if (total > 0) {
|
package/src/commands/ps.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
fetchManagedPs,
|
|
16
16
|
type ManagedProcessEntry,
|
|
17
17
|
} from "../lib/health-check";
|
|
18
|
+
import { readPlatformToken } from "../lib/platform-client";
|
|
18
19
|
import { dockerResourceNames } from "../lib/docker";
|
|
19
20
|
import { existsSync } from "fs";
|
|
20
21
|
import {
|
|
@@ -472,7 +473,7 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
|
|
|
472
473
|
|
|
473
474
|
// ── List all assistants (no arg) ────────────────────────────────
|
|
474
475
|
|
|
475
|
-
async function listAllAssistants(verbose: boolean): Promise<void> {
|
|
476
|
+
export async function listAllAssistants(verbose: boolean): Promise<void> {
|
|
476
477
|
const { name: envName, source: envSource } = resolveEnvironmentSource();
|
|
477
478
|
const sourceLabels: Record<typeof envSource, string> = {
|
|
478
479
|
flag: "--environment flag",
|
|
@@ -486,23 +487,33 @@ async function listAllAssistants(verbose: boolean): Promise<void> {
|
|
|
486
487
|
? (msg) => console.log(` [verbose] ${msg}`)
|
|
487
488
|
: undefined;
|
|
488
489
|
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if (syncResult.added > 0 || syncResult.removed > 0) {
|
|
497
|
-
const changes: string[] = [];
|
|
498
|
-
if (syncResult.added > 0) changes.push(`${syncResult.added} added`);
|
|
499
|
-
if (syncResult.removed > 0)
|
|
500
|
-
changes.push(`${syncResult.removed} removed`);
|
|
501
|
-
parts.push(`(${changes.join(", ")})`);
|
|
502
|
-
}
|
|
503
|
-
console.log(parts.join(" "));
|
|
504
|
-
} else {
|
|
490
|
+
// Decide platform login status FIRST, before touching the network. With no
|
|
491
|
+
// local token we never enter the platform fetch path — so unreachable-host
|
|
492
|
+
// errors from the org-ID/user lookups can't leak onto stderr ahead of the
|
|
493
|
+
// "Platform: not logged in" line.
|
|
494
|
+
const platformToken = readPlatformToken();
|
|
495
|
+
if (!platformToken) {
|
|
496
|
+
log?.("No platform token found — skipping cloud sync");
|
|
505
497
|
console.log("Platform: not logged in");
|
|
498
|
+
} else {
|
|
499
|
+
const syncResult = await syncCloudAssistants(platformToken, { log });
|
|
500
|
+
if (syncResult) {
|
|
501
|
+
const parts = [`Platform: logged in`];
|
|
502
|
+
if (syncResult.email) parts[0] += ` as ${syncResult.email}`;
|
|
503
|
+
if (syncResult.added > 0 || syncResult.removed > 0) {
|
|
504
|
+
const changes: string[] = [];
|
|
505
|
+
if (syncResult.added > 0) changes.push(`${syncResult.added} added`);
|
|
506
|
+
if (syncResult.removed > 0)
|
|
507
|
+
changes.push(`${syncResult.removed} removed`);
|
|
508
|
+
parts.push(`(${changes.join(", ")})`);
|
|
509
|
+
}
|
|
510
|
+
console.log(parts.join(" "));
|
|
511
|
+
} else {
|
|
512
|
+
// We had a token but the platform fetch failed (offline, expired, etc.).
|
|
513
|
+
// Treat it the same as "not logged in" from a UX perspective — the user
|
|
514
|
+
// can't reach cloud-managed assistants right now either way.
|
|
515
|
+
console.log("Platform: not logged in");
|
|
516
|
+
}
|
|
506
517
|
}
|
|
507
518
|
console.log("");
|
|
508
519
|
|
|
@@ -333,6 +333,8 @@ interface SseEvent {
|
|
|
333
333
|
allowedDomains?: string[];
|
|
334
334
|
// message_complete fields
|
|
335
335
|
source?: "main" | "aux";
|
|
336
|
+
// sync_changed fields
|
|
337
|
+
tags?: string[];
|
|
336
338
|
[key: string]: unknown;
|
|
337
339
|
}
|
|
338
340
|
|
|
@@ -1856,6 +1858,11 @@ function ChatApp({
|
|
|
1856
1858
|
hRef.setBusy(false);
|
|
1857
1859
|
break;
|
|
1858
1860
|
|
|
1861
|
+
case "sync_changed":
|
|
1862
|
+
// The interactive CLI does not currently keep any sync-tagged
|
|
1863
|
+
// caches, so generic invalidations are intentionally ignored.
|
|
1864
|
+
break;
|
|
1865
|
+
|
|
1859
1866
|
default:
|
|
1860
1867
|
// Ignore events we don't handle (activity state, traces, etc.)
|
|
1861
1868
|
break;
|
|
@@ -2265,15 +2272,7 @@ function ChatApp({
|
|
|
2265
2272
|
// racing with SSE events that may arrive during the sendMessage await.
|
|
2266
2273
|
h.showSpinner("Working...");
|
|
2267
2274
|
},
|
|
2268
|
-
[
|
|
2269
|
-
runtimeUrl,
|
|
2270
|
-
assistantId,
|
|
2271
|
-
auth,
|
|
2272
|
-
project,
|
|
2273
|
-
zone,
|
|
2274
|
-
cleanup,
|
|
2275
|
-
ensureConnected,
|
|
2276
|
-
],
|
|
2275
|
+
[runtimeUrl, assistantId, auth, project, zone, cleanup, ensureConnected],
|
|
2277
2276
|
);
|
|
2278
2277
|
|
|
2279
2278
|
const handleSubmit = useCallback(
|
|
@@ -128,6 +128,17 @@ describe("buildServiceRunArgs — gateway", () => {
|
|
|
128
128
|
buildGatewayArgs().some((arg) => arg.startsWith("VELAY_BASE_URL=")),
|
|
129
129
|
).toBe(false);
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
test("forces gateway to run as uid 0 so it can connect to the assistant's root-owned IPC socket (mirrors K8s securityContext.runAsUser=0)", () => {
|
|
133
|
+
const args = buildGatewayArgs();
|
|
134
|
+
const userIdx = args.indexOf("--user");
|
|
135
|
+
expect(userIdx).toBeGreaterThan(-1);
|
|
136
|
+
expect(args[userIdx + 1]).toBe("0");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("assistant container does NOT get a --user override (image USER root wins)", () => {
|
|
140
|
+
expect(buildAssistantArgs().includes("--user")).toBe(false);
|
|
141
|
+
});
|
|
131
142
|
});
|
|
132
143
|
|
|
133
144
|
describe("VELLUM_AVATAR_DEVICE passthrough", () => {
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
getMultiInstanceDir,
|
|
19
19
|
} from "./environments/paths.js";
|
|
20
20
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
21
|
+
import { SEEDS } from "./environments/seeds.js";
|
|
22
|
+
import type { EnvironmentDefinition } from "./environments/types.js";
|
|
21
23
|
import { probePort } from "./port-probe.js";
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -327,6 +329,69 @@ export function loadAllAssistants(): AssistantEntry[] {
|
|
|
327
329
|
return readAssistants();
|
|
328
330
|
}
|
|
329
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Read the first existing lockfile for an explicitly-provided environment,
|
|
334
|
+
* without applying legacy migrations. This is the cross-env read path used by
|
|
335
|
+
* {@link loadAllAssistantsAcrossEnvs}: it deliberately bypasses
|
|
336
|
+
* {@link readLockfile} (which always resolves the *current* env) so callers
|
|
337
|
+
* can enumerate state from every env without flipping `process.env` or the
|
|
338
|
+
* persisted default. Migrations are skipped because we never want to write
|
|
339
|
+
* to another env's lockfile from the current env's process.
|
|
340
|
+
*/
|
|
341
|
+
function readLockfileForEnv(env: EnvironmentDefinition): LockfileData {
|
|
342
|
+
for (const lockfilePath of getLockfilePaths(env)) {
|
|
343
|
+
if (!existsSync(lockfilePath)) continue;
|
|
344
|
+
try {
|
|
345
|
+
const raw = readFileSync(lockfilePath, "utf-8");
|
|
346
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
347
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
348
|
+
return parsed as LockfileData;
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// Malformed; try next candidate
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return {};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Load assistant entries from every known environment's lockfile.
|
|
359
|
+
*
|
|
360
|
+
* Each {@link SEEDS} entry has its own on-host data layout (config dir,
|
|
361
|
+
* lockfile path, data dir). A running assistant from `dev` is invisible to
|
|
362
|
+
* `loadAllAssistants()` when the current env is `local`, but its host
|
|
363
|
+
* processes (daemon/gateway/qdrant) still show up in `ps ax`. The orphan
|
|
364
|
+
* detector and `vellum clean` need the union of all envs' entries to avoid
|
|
365
|
+
* misclassifying — or worse, killing — another env's running services.
|
|
366
|
+
*
|
|
367
|
+
* Optional `envs` override is provided for testability so call sites can
|
|
368
|
+
* inject a curated env list with `lockfileDirOverride` set, without having
|
|
369
|
+
* to manipulate the global SEEDS table or process.env.
|
|
370
|
+
*/
|
|
371
|
+
export function loadAllAssistantsAcrossEnvs(
|
|
372
|
+
envs?: EnvironmentDefinition[],
|
|
373
|
+
): AssistantEntry[] {
|
|
374
|
+
const envList = envs ?? Object.values(SEEDS).map((env) => ({ ...env }));
|
|
375
|
+
const all: AssistantEntry[] = [];
|
|
376
|
+
for (const env of envList) {
|
|
377
|
+
const data = readLockfileForEnv(env);
|
|
378
|
+
const entries = data.assistants;
|
|
379
|
+
if (!Array.isArray(entries)) continue;
|
|
380
|
+
for (const raw of entries) {
|
|
381
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
382
|
+
const entry = raw as AssistantEntry;
|
|
383
|
+
if (
|
|
384
|
+
typeof entry.assistantId !== "string" ||
|
|
385
|
+
typeof entry.runtimeUrl !== "string"
|
|
386
|
+
) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
all.push(entry);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return all;
|
|
393
|
+
}
|
|
394
|
+
|
|
330
395
|
export function getActiveAssistant(): string | null {
|
|
331
396
|
const data = readLockfile();
|
|
332
397
|
return data.activeAssistant ?? null;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
2
3
|
|
|
4
|
+
import {
|
|
5
|
+
getDaemonPidPath,
|
|
6
|
+
loadAllAssistantsAcrossEnvs,
|
|
7
|
+
type AssistantEntry,
|
|
8
|
+
} from "./assistant-config.js";
|
|
3
9
|
import { execOutput } from "./step-runner";
|
|
4
10
|
|
|
5
11
|
export interface RemoteProcess {
|
|
@@ -67,10 +73,68 @@ export interface OrphanedProcess {
|
|
|
67
73
|
source: string;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Collect PIDs that belong to a known assistant in any environment.
|
|
78
|
+
*
|
|
79
|
+
* For local entries this reads the daemon/gateway/qdrant/embed-worker PID
|
|
80
|
+
* files under each entry's `instanceDir`. For docker entries we include the
|
|
81
|
+
* `watcherPid` field when present (the file watcher runs as a host process,
|
|
82
|
+
* unlike the containers themselves). Other cloud topologies don't have
|
|
83
|
+
* host-side processes that show up in `ps ax`.
|
|
84
|
+
*
|
|
85
|
+
* This set is the basis for filtering the orphan list: if a running process
|
|
86
|
+
* matches a recorded PID for *any* env's assistant, it's not an orphan.
|
|
87
|
+
*/
|
|
88
|
+
export function getKnownPidsFromAssistants(
|
|
89
|
+
entries: AssistantEntry[],
|
|
90
|
+
): Set<string> {
|
|
91
|
+
const pids = new Set<string>();
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (entry.cloud === "local" && entry.resources) {
|
|
94
|
+
const vellumDir = join(entry.resources.instanceDir, ".vellum");
|
|
95
|
+
const candidates = [
|
|
96
|
+
getDaemonPidPath(entry.resources),
|
|
97
|
+
join(vellumDir, "gateway.pid"),
|
|
98
|
+
join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"),
|
|
99
|
+
join(vellumDir, "workspace", "embed-worker.pid"),
|
|
100
|
+
];
|
|
101
|
+
for (const file of candidates) {
|
|
102
|
+
const pid = readPidFile(file);
|
|
103
|
+
if (pid) pids.add(pid);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (typeof entry.watcherPid === "number") {
|
|
107
|
+
pids.add(String(entry.watcherPid));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return pids;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface DetectOrphansOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Set of PIDs to treat as known and exclude from the orphan list. When
|
|
116
|
+
* omitted, defaults to the union of every env's recorded assistant PIDs
|
|
117
|
+
* via {@link loadAllAssistantsAcrossEnvs} +
|
|
118
|
+
* {@link getKnownPidsFromAssistants}. Tests can inject an explicit set to
|
|
119
|
+
* avoid touching the real on-host lockfiles.
|
|
120
|
+
*/
|
|
121
|
+
excludePids?: Set<string>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function detectOrphanedProcesses(
|
|
125
|
+
options: DetectOrphansOptions = {},
|
|
126
|
+
): Promise<OrphanedProcess[]> {
|
|
71
127
|
const results: OrphanedProcess[] = [];
|
|
72
128
|
const seenPids = new Set<string>();
|
|
73
129
|
|
|
130
|
+
// PIDs that belong to a known assistant in *any* environment are not
|
|
131
|
+
// orphans. Without this filter, running `vellum ps` from an env that has
|
|
132
|
+
// no assistants — or `vellum clean` from any env — would flag (or kill)
|
|
133
|
+
// another env's healthy services as orphans.
|
|
134
|
+
const knownPids =
|
|
135
|
+
options.excludePids ??
|
|
136
|
+
getKnownPidsFromAssistants(loadAllAssistantsAcrossEnvs());
|
|
137
|
+
|
|
74
138
|
// Process table scan — discover orphaned processes by scanning the OS
|
|
75
139
|
// process table rather than reading PID files from the workspace.
|
|
76
140
|
try {
|
|
@@ -83,6 +147,7 @@ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
|
|
|
83
147
|
|
|
84
148
|
for (const p of procs) {
|
|
85
149
|
if (p.pid === ownPid || seenPids.has(p.pid)) continue;
|
|
150
|
+
if (knownPids.has(p.pid)) continue;
|
|
86
151
|
const type = classifyProcess(p.command);
|
|
87
152
|
if (type === "unknown") continue;
|
|
88
153
|
results.push({ name: type, pid: p.pid, source: "process table" });
|
|
@@ -129,9 +129,12 @@ export function invalidateOrgIdCache(
|
|
|
129
129
|
* The org ID is cached per (token, platformUrl) for 60 seconds to avoid
|
|
130
130
|
* redundant HTTP requests in tight polling loops.
|
|
131
131
|
*
|
|
132
|
-
* Auth errors (401 / 403) from the org-ID fetch are
|
|
133
|
-
* user-friendly message before re-throwing, so callers
|
|
134
|
-
*
|
|
132
|
+
* Auth errors (401 / 403) from the org-ID fetch are wrapped in a
|
|
133
|
+
* user-friendly Error message before re-throwing, so callers can surface
|
|
134
|
+
* a useful message without doing their own classification. Callers that
|
|
135
|
+
* handle the throw (e.g. `syncCloudAssistants`) stay silent on stderr;
|
|
136
|
+
* callers that let it bubble get a single clean line from the top-level
|
|
137
|
+
* runner.
|
|
135
138
|
*/
|
|
136
139
|
export async function authHeaders(
|
|
137
140
|
token: string,
|
|
@@ -163,11 +166,9 @@ export async function authHeaders(
|
|
|
163
166
|
} catch (err) {
|
|
164
167
|
const msg = err instanceof Error ? err.message : String(err);
|
|
165
168
|
if (msg.includes("401") || msg.includes("403")) {
|
|
166
|
-
|
|
167
|
-
} else {
|
|
168
|
-
console.error(`Failed to fetch organization: ${msg}`);
|
|
169
|
+
throw new Error("Authentication failed. Run 'vellum login' to refresh.");
|
|
169
170
|
}
|
|
170
|
-
throw
|
|
171
|
+
throw new Error(`Failed to fetch organization: ${msg}`);
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
|
package/src/lib/statefulset.ts
CHANGED
|
@@ -99,6 +99,11 @@ export interface DockerContainerSpec {
|
|
|
99
99
|
ports?: PortSpec[];
|
|
100
100
|
env: EnvEntry[];
|
|
101
101
|
volumeMounts: VolumeMount[];
|
|
102
|
+
/**
|
|
103
|
+
* Optional `--user` override for `docker run`. Mirrors K8s
|
|
104
|
+
* `securityContext.runAsUser`. Omitted ⇒ image's `USER` directive wins.
|
|
105
|
+
*/
|
|
106
|
+
user?: string;
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
export interface DockerVolumeClaimTemplate {
|
|
@@ -168,6 +173,7 @@ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
|
|
|
168
173
|
{ kind: "static", name: "DEBUG_STDOUT_LOGS", value: "1" },
|
|
169
174
|
{ kind: "static", name: "VELLUM_CLOUD", value: "docker" },
|
|
170
175
|
{ kind: "static", name: "RUNTIME_HTTP_HOST", value: "0.0.0.0" },
|
|
176
|
+
{ kind: "static", name: "RUNTIME_HTTP_PORT", value: `${ASSISTANT_INTERNAL_PORT}` },
|
|
171
177
|
{ kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
|
|
172
178
|
{ kind: "static", name: "VELLUM_BACKUP_DIR", value: "/workspace/.backups" },
|
|
173
179
|
{ kind: "static", name: "VELLUM_BACKUP_KEY_PATH", value: "/workspace/.backup.key" },
|
|
@@ -196,6 +202,7 @@ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
|
|
|
196
202
|
name: "gateway-sidecar",
|
|
197
203
|
internalName: "gateway",
|
|
198
204
|
network: "container",
|
|
205
|
+
user: "0",
|
|
199
206
|
env: [
|
|
200
207
|
{ kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
|
|
201
208
|
{ kind: "static", name: "GATEWAY_SECURITY_DIR", value: "/gateway-security" },
|
|
@@ -300,6 +307,11 @@ export function buildServiceRunArgs(
|
|
|
300
307
|
: res.cesContainer;
|
|
301
308
|
args.push("--name", containerName);
|
|
302
309
|
|
|
310
|
+
// User override (mirrors K8s securityContext.runAsUser)
|
|
311
|
+
if (container.user !== undefined) {
|
|
312
|
+
args.push("--user", container.user);
|
|
313
|
+
}
|
|
314
|
+
|
|
303
315
|
// Network
|
|
304
316
|
if (container.network === "bridge") {
|
|
305
317
|
args.push(`--network=${res.network}`);
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* (e.g. retired assistants).
|
|
7
7
|
*
|
|
8
8
|
* Used by both `vellum login` and `vellum ps` to keep the lockfile fresh.
|
|
9
|
+
*
|
|
10
|
+
* **Contract:** callers must verify the user is logged in (i.e. a non-empty
|
|
11
|
+
* platform token exists) before invoking this helper. The "is there a token?"
|
|
12
|
+
* decision belongs at the command level so commands can render the right
|
|
13
|
+
* "Platform: …" status without ever entering the platform fetch path.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import {
|
|
@@ -17,7 +22,6 @@ import {
|
|
|
17
22
|
fetchCurrentUser,
|
|
18
23
|
fetchPlatformAssistants,
|
|
19
24
|
getPlatformUrl,
|
|
20
|
-
readPlatformToken,
|
|
21
25
|
} from "./platform-client.js";
|
|
22
26
|
|
|
23
27
|
export type SyncLogger = (message: string) => void;
|
|
@@ -34,21 +38,24 @@ export interface SyncOptions {
|
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
40
|
* Fetch platform assistants and reconcile against the lockfile.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
41
|
+
*
|
|
42
|
+
* Returns the number of entries added/removed, or `null` if the fetch fails
|
|
43
|
+
* (e.g. platform unreachable, invalid token). Callers must pre-verify a
|
|
44
|
+
* non-empty token; this function assumes one is present and will throw if
|
|
45
|
+
* called with an empty string.
|
|
39
46
|
*/
|
|
40
47
|
export async function syncCloudAssistants(
|
|
48
|
+
token: string,
|
|
41
49
|
options?: SyncOptions,
|
|
42
50
|
): Promise<SyncResult | null> {
|
|
51
|
+
if (!token) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"syncCloudAssistants called without a token. Callers must check `readPlatformToken()` first.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
43
56
|
const log = options?.log;
|
|
44
57
|
const platformUrl = getPlatformUrl();
|
|
45
58
|
log?.(`Platform URL: ${platformUrl}`);
|
|
46
|
-
|
|
47
|
-
const token = readPlatformToken();
|
|
48
|
-
if (!token) {
|
|
49
|
-
log?.("No platform token found — skipping cloud sync");
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
59
|
log?.(
|
|
53
60
|
`Token found (${token.length} chars, prefix: ${token.slice(0, 6)}…)`,
|
|
54
61
|
);
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
import { getStateDir } from "./environments/paths.js";
|
|
19
19
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
20
20
|
import { loadGuardianToken } from "./guardian-token.js";
|
|
21
|
-
import { getPlatformUrl } from "./platform-client.js";
|
|
22
21
|
import { resolveImageRefs } from "./platform-releases.js";
|
|
23
22
|
import { exec, execOutput } from "./step-runner.js";
|
|
24
23
|
import { compareVersions } from "./version-compat.js";
|
|
@@ -481,42 +480,6 @@ export async function performDockerRollback(
|
|
|
481
480
|
console.log("🔍 Resolving image references...");
|
|
482
481
|
const { imageTags: targetImageTags } = await resolveImageRefs(targetVersion);
|
|
483
482
|
|
|
484
|
-
// Fetch target migration ceiling from releases API
|
|
485
|
-
let targetMigrationCeiling: {
|
|
486
|
-
dbVersion?: number;
|
|
487
|
-
workspaceMigrationId?: string;
|
|
488
|
-
} = {};
|
|
489
|
-
try {
|
|
490
|
-
const platformUrl = getPlatformUrl();
|
|
491
|
-
const releasesResp = await fetch(
|
|
492
|
-
`${platformUrl}/v1/releases/?stable=true`,
|
|
493
|
-
{ signal: AbortSignal.timeout(10000) },
|
|
494
|
-
);
|
|
495
|
-
if (releasesResp.ok) {
|
|
496
|
-
const releases = (await releasesResp.json()) as Array<{
|
|
497
|
-
version: string;
|
|
498
|
-
db_migration_version?: number | null;
|
|
499
|
-
last_workspace_migration_id?: string;
|
|
500
|
-
}>;
|
|
501
|
-
const normalizedTag = targetVersion.replace(/^v/, "");
|
|
502
|
-
const targetRelease = releases.find(
|
|
503
|
-
(r) => r.version?.replace(/^v/, "") === normalizedTag,
|
|
504
|
-
);
|
|
505
|
-
if (
|
|
506
|
-
targetRelease?.db_migration_version != null ||
|
|
507
|
-
targetRelease?.last_workspace_migration_id
|
|
508
|
-
) {
|
|
509
|
-
targetMigrationCeiling = {
|
|
510
|
-
dbVersion: targetRelease.db_migration_version ?? undefined,
|
|
511
|
-
workspaceMigrationId:
|
|
512
|
-
targetRelease.last_workspace_migration_id || undefined,
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
} catch {
|
|
517
|
-
// Best-effort — fall back to rollbackToRegistryCeiling post-swap
|
|
518
|
-
}
|
|
519
|
-
|
|
520
483
|
// Capture current image digests for auto-rollback on failure
|
|
521
484
|
console.log("📸 Capturing current image references for rollback...");
|
|
522
485
|
const currentImageRefs = await captureImageRefs(res);
|
|
@@ -702,26 +665,6 @@ export async function performDockerRollback(
|
|
|
702
665
|
}
|
|
703
666
|
console.log("✅ Docker images pulled\n");
|
|
704
667
|
|
|
705
|
-
// Pre-swap migration rollback to target ceiling on the CURRENT (newer) daemon
|
|
706
|
-
let preSwapRollbackOk = true;
|
|
707
|
-
if (
|
|
708
|
-
targetMigrationCeiling.dbVersion !== undefined ||
|
|
709
|
-
targetMigrationCeiling.workspaceMigrationId !== undefined
|
|
710
|
-
) {
|
|
711
|
-
console.log("🔄 Reverting database changes...");
|
|
712
|
-
await broadcastUpgradeEvent(
|
|
713
|
-
entry.runtimeUrl,
|
|
714
|
-
entry.assistantId,
|
|
715
|
-
buildProgressEvent(UPGRADE_PROGRESS.REVERTING_MIGRATIONS),
|
|
716
|
-
);
|
|
717
|
-
preSwapRollbackOk = await rollbackMigrations(
|
|
718
|
-
entry.runtimeUrl,
|
|
719
|
-
entry.assistantId,
|
|
720
|
-
targetMigrationCeiling.dbVersion,
|
|
721
|
-
targetMigrationCeiling.workspaceMigrationId,
|
|
722
|
-
);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
668
|
// Progress: switching version
|
|
726
669
|
await broadcastUpgradeEvent(
|
|
727
670
|
entry.runtimeUrl,
|
|
@@ -757,22 +700,15 @@ export async function performDockerRollback(
|
|
|
757
700
|
if (ready) {
|
|
758
701
|
// Success path
|
|
759
702
|
|
|
760
|
-
// Post-swap migration rollback
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
entry.runtimeUrl,
|
|
770
|
-
entry.assistantId,
|
|
771
|
-
undefined,
|
|
772
|
-
undefined,
|
|
773
|
-
true,
|
|
774
|
-
);
|
|
775
|
-
}
|
|
703
|
+
// Post-swap migration rollback: ask the now-running old daemon to roll
|
|
704
|
+
// back any migrations above its own registry ceiling.
|
|
705
|
+
await rollbackMigrations(
|
|
706
|
+
entry.runtimeUrl,
|
|
707
|
+
entry.assistantId,
|
|
708
|
+
undefined,
|
|
709
|
+
undefined,
|
|
710
|
+
true,
|
|
711
|
+
);
|
|
776
712
|
|
|
777
713
|
// Capture new digests from the rolled-back containers
|
|
778
714
|
const newDigests = await captureImageRefs(res);
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Provider API key environment variable names, keyed by provider ID.
|
|
3
3
|
*
|
|
4
|
-
* Two sources are merged into a single combined map
|
|
4
|
+
* Two sources are merged into a single combined map. Both are locally-
|
|
5
|
+
* maintained mirrors of canonical catalogs in `assistant/src/providers/`
|
|
6
|
+
* — the CLI does not import from `assistant/src/`, so drift is caught by
|
|
7
|
+
* dedicated parity tests:
|
|
5
8
|
*
|
|
6
|
-
* 1.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* 1. LLM-provider env vars — mirrors `PROVIDER_CATALOG` entries with an
|
|
10
|
+
* `envVar`. Drift guard: `cli/src/__tests__/llm-provider-env-var-parity.test.ts`.
|
|
11
|
+
* 2. Search-provider env vars — mirrors `SEARCH_PROVIDER_CATALOG`
|
|
12
|
+
* entries with an `envVar`. Drift guard:
|
|
13
|
+
* `cli/src/__tests__/search-provider-env-var-parity.test.ts`.
|
|
11
14
|
*
|
|
12
15
|
* The combined map is what cloud-infra code (docker.ts, aws.ts, gcp.ts)
|
|
13
16
|
* iterates to forward provider API keys from the caller's environment into
|
|
@@ -25,10 +28,11 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
|
25
28
|
openrouter: "OPENROUTER_API_KEY",
|
|
26
29
|
};
|
|
27
30
|
|
|
28
|
-
/** Search-provider env var names. */
|
|
31
|
+
/** Search-provider env var names. Mirrors `SEARCH_PROVIDER_CATALOG` BYOK entries. */
|
|
29
32
|
export const SEARCH_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
30
|
-
brave: "BRAVE_API_KEY",
|
|
31
33
|
perplexity: "PERPLEXITY_API_KEY",
|
|
34
|
+
brave: "BRAVE_API_KEY",
|
|
35
|
+
tavily: "TAVILY_API_KEY",
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
/**
|