@vellumai/cli 0.8.0 → 0.8.2
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__/backup.test.ts +13 -3
- package/src/__tests__/input-history.test.ts +102 -0
- package/src/__tests__/orphan-detection.test.ts +287 -0
- package/src/__tests__/preload.ts +5 -1
- package/src/__tests__/provider-secrets.test.ts +290 -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__/setup.test.ts +296 -0
- package/src/__tests__/sync-events.test.ts +54 -0
- package/src/__tests__/teleport.test.ts +190 -163
- package/src/commands/client.ts +128 -10
- package/src/commands/events.ts +13 -1
- package/src/commands/login.ts +3 -2
- package/src/commands/ps.ts +28 -17
- package/src/commands/setup.ts +101 -96
- package/src/components/DefaultMainScreen.tsx +80 -128
- package/src/lib/__tests__/docker.test.ts +11 -0
- package/src/lib/assistant-config.ts +69 -2
- package/src/lib/client-identity.ts +1 -0
- package/src/lib/environments/paths.ts +21 -0
- package/src/lib/input-history.ts +5 -8
- package/src/lib/orphan-detection.ts +66 -1
- package/src/lib/platform-client.ts +8 -7
- package/src/lib/provider-secrets.ts +413 -0
- package/src/lib/statefulset.ts +12 -0
- package/src/lib/sync-cloud-assistants.ts +39 -18
- package/src/lib/upgrade-lifecycle.ts +9 -73
- package/src/shared/provider-env-vars.ts +15 -8
- package/src/lib/doctor-client.ts +0 -153
|
@@ -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
|
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { LLM_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
4
|
+
|
|
5
|
+
export type LlmProviderId = keyof typeof LLM_PROVIDER_ENV_VAR_NAMES;
|
|
6
|
+
|
|
7
|
+
export type ProviderApiKeySource = "env" | "prompt";
|
|
8
|
+
export type ProviderSecretFetch = (
|
|
9
|
+
input: Parameters<typeof fetch>[0],
|
|
10
|
+
init?: Parameters<typeof fetch>[1],
|
|
11
|
+
) => ReturnType<typeof fetch>;
|
|
12
|
+
|
|
13
|
+
export type EnsureProviderApiKeyResult =
|
|
14
|
+
| {
|
|
15
|
+
status: "already_configured";
|
|
16
|
+
provider: LlmProviderId;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
status: "configured";
|
|
20
|
+
provider: LlmProviderId;
|
|
21
|
+
source: ProviderApiKeySource;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
status: "missing";
|
|
25
|
+
provider: LlmProviderId;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
status: "failed";
|
|
30
|
+
provider: LlmProviderId;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
status: "skipped";
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface EnsureProviderApiKeyOptions {
|
|
39
|
+
gatewayUrl: string;
|
|
40
|
+
provider: string | null;
|
|
41
|
+
bearerToken?: string;
|
|
42
|
+
env?: NodeJS.ProcessEnv;
|
|
43
|
+
fetchImpl?: ProviderSecretFetch;
|
|
44
|
+
prompt?: (prompt: string) => Promise<string>;
|
|
45
|
+
stdinIsTTY?: boolean;
|
|
46
|
+
input?: NodeJS.ReadStream;
|
|
47
|
+
output?: NodeJS.WriteStream;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GatewayApiKeyReadResult {
|
|
51
|
+
found: boolean;
|
|
52
|
+
unreachable: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
|
56
|
+
anthropic: "Anthropic",
|
|
57
|
+
openai: "OpenAI",
|
|
58
|
+
gemini: "Gemini",
|
|
59
|
+
fireworks: "Fireworks",
|
|
60
|
+
openrouter: "OpenRouter",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function formatProviderName(provider: LlmProviderId): string {
|
|
64
|
+
return PROVIDER_LABELS[provider];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isSupportedLlmProvider(
|
|
68
|
+
provider: string,
|
|
69
|
+
): provider is LlmProviderId {
|
|
70
|
+
return Object.hasOwn(LLM_PROVIDER_ENV_VAR_NAMES, provider);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function gatewayUrlWithPath(gatewayUrl: string, path: string): string {
|
|
74
|
+
return `${gatewayUrl.replace(/\/+$/, "")}${path}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function secretHeaders(bearerToken?: string): Record<string, string> {
|
|
78
|
+
const headers: Record<string, string> = {
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
};
|
|
82
|
+
if (bearerToken) {
|
|
83
|
+
headers.Authorization = `Bearer ${bearerToken}`;
|
|
84
|
+
}
|
|
85
|
+
return headers;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function parseErrorMessage(response: Response): Promise<string> {
|
|
89
|
+
let text = "";
|
|
90
|
+
try {
|
|
91
|
+
text = await response.text();
|
|
92
|
+
} catch {
|
|
93
|
+
// Fall through to status text.
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const body = JSON.parse(text) as {
|
|
98
|
+
error?: unknown;
|
|
99
|
+
};
|
|
100
|
+
const message = extractErrorMessage(body.error);
|
|
101
|
+
if (message) return message;
|
|
102
|
+
} catch {
|
|
103
|
+
// Fall back to raw text below.
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (text.trim().length > 0) {
|
|
107
|
+
return text.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return response.statusText || `HTTP ${response.status}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractErrorMessage(error: unknown): string | null {
|
|
114
|
+
if (typeof error === "string" && error.trim().length > 0) {
|
|
115
|
+
return error.trim();
|
|
116
|
+
}
|
|
117
|
+
if (!error || typeof error !== "object") {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const maybeMessage = (error as { message?: unknown }).message;
|
|
122
|
+
if (typeof maybeMessage === "string" && maybeMessage.trim().length > 0) {
|
|
123
|
+
return maybeMessage.trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function readGatewayApiKey(
|
|
130
|
+
gatewayUrl: string,
|
|
131
|
+
provider: LlmProviderId,
|
|
132
|
+
bearerToken?: string,
|
|
133
|
+
fetchImpl: ProviderSecretFetch = fetch,
|
|
134
|
+
): Promise<GatewayApiKeyReadResult> {
|
|
135
|
+
const response = await fetchImpl(
|
|
136
|
+
gatewayUrlWithPath(gatewayUrl, "/v1/secrets/read"),
|
|
137
|
+
{
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: secretHeaders(bearerToken),
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
type: "api_key",
|
|
142
|
+
name: provider,
|
|
143
|
+
reveal: false,
|
|
144
|
+
}),
|
|
145
|
+
signal: AbortSignal.timeout(10_000),
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
const message = await parseErrorMessage(response);
|
|
151
|
+
if (response.status === 404) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Active assistant at ${gatewayUrl} does not expose /v1/secrets/read (${message}). Run \`vellum ps\` to confirm the active assistant, then select a self-hosted assistant with \`vellum use <assistant>\` or wake a current assistant before running setup.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Failed to check ${formatProviderName(provider)} API key: ${message}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const body = (await response.json()) as {
|
|
162
|
+
found?: unknown;
|
|
163
|
+
unreachable?: unknown;
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
found: body.found === true,
|
|
167
|
+
unreachable: body.unreachable === true,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function injectGatewayApiKey(
|
|
172
|
+
gatewayUrl: string,
|
|
173
|
+
provider: LlmProviderId,
|
|
174
|
+
value: string,
|
|
175
|
+
bearerToken?: string,
|
|
176
|
+
fetchImpl: ProviderSecretFetch = fetch,
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
const response = await fetchImpl(
|
|
179
|
+
gatewayUrlWithPath(gatewayUrl, "/v1/secrets"),
|
|
180
|
+
{
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: secretHeaders(bearerToken),
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
type: "api_key",
|
|
185
|
+
name: provider,
|
|
186
|
+
value,
|
|
187
|
+
}),
|
|
188
|
+
signal: AbortSignal.timeout(10_000),
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const message = await parseErrorMessage(response);
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Failed to store ${formatProviderName(provider)} API key: ${message}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
200
|
+
success?: unknown;
|
|
201
|
+
error?: unknown;
|
|
202
|
+
};
|
|
203
|
+
if (body.success === false) {
|
|
204
|
+
const message =
|
|
205
|
+
typeof body.error === "string" && body.error.trim().length > 0
|
|
206
|
+
? body.error
|
|
207
|
+
: "Assistant rejected the API key.";
|
|
208
|
+
throw new Error(message);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function promptSecret(
|
|
213
|
+
prompt: string,
|
|
214
|
+
streams: {
|
|
215
|
+
input?: NodeJS.ReadStream;
|
|
216
|
+
output?: NodeJS.WriteStream;
|
|
217
|
+
} = {},
|
|
218
|
+
): Promise<string> {
|
|
219
|
+
const input = streams.input ?? process.stdin;
|
|
220
|
+
const output = streams.output ?? process.stdout;
|
|
221
|
+
|
|
222
|
+
const restoreEcho = disableTerminalEcho(input);
|
|
223
|
+
output.write(prompt);
|
|
224
|
+
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
const wasRaw = input.isRaw;
|
|
227
|
+
if (input.isTTY) {
|
|
228
|
+
input.setRawMode(true);
|
|
229
|
+
}
|
|
230
|
+
input.resume();
|
|
231
|
+
|
|
232
|
+
let value = "";
|
|
233
|
+
|
|
234
|
+
const cleanup = (): void => {
|
|
235
|
+
input.removeListener("data", onData);
|
|
236
|
+
if (input.isTTY) {
|
|
237
|
+
input.setRawMode(wasRaw ?? false);
|
|
238
|
+
}
|
|
239
|
+
restoreEcho();
|
|
240
|
+
input.pause();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const finish = (): void => {
|
|
244
|
+
cleanup();
|
|
245
|
+
output.write("\n");
|
|
246
|
+
resolve(value);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const cancel = (): void => {
|
|
250
|
+
cleanup();
|
|
251
|
+
output.write("\n");
|
|
252
|
+
reject(new Error("Input cancelled."));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const onData = (chunk: Buffer | string): void => {
|
|
256
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
257
|
+
if (bytes[0] === 27) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const byte of bytes) {
|
|
262
|
+
if (byte === 3) {
|
|
263
|
+
cancel();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (byte === 10 || byte === 13) {
|
|
267
|
+
finish();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (byte === 8 || byte === 127) {
|
|
271
|
+
value = value.slice(0, -1);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (byte >= 32 && byte <= 126) {
|
|
275
|
+
value += String.fromCharCode(byte);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
input.on("data", onData);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function disableTerminalEcho(input: NodeJS.ReadStream): () => void {
|
|
285
|
+
if (input !== process.stdin || !input.isTTY || process.platform === "win32") {
|
|
286
|
+
return () => {};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const currentState = spawnSync("stty", ["-g"], {
|
|
290
|
+
encoding: "utf8",
|
|
291
|
+
stdio: ["inherit", "pipe", "ignore"],
|
|
292
|
+
});
|
|
293
|
+
const state = currentState.stdout.trim();
|
|
294
|
+
if (currentState.status !== 0 || state.length === 0) {
|
|
295
|
+
return () => {};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const disabled = spawnSync("stty", ["-echo"], {
|
|
299
|
+
stdio: ["inherit", "ignore", "ignore"],
|
|
300
|
+
});
|
|
301
|
+
if (disabled.status !== 0) {
|
|
302
|
+
return () => {};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let restored = false;
|
|
306
|
+
return () => {
|
|
307
|
+
if (restored) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
restored = true;
|
|
311
|
+
spawnSync("stty", [state], {
|
|
312
|
+
stdio: ["inherit", "ignore", "ignore"],
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function ensureProviderApiKey(
|
|
318
|
+
options: EnsureProviderApiKeyOptions,
|
|
319
|
+
): Promise<EnsureProviderApiKeyResult> {
|
|
320
|
+
if (options.provider === null) {
|
|
321
|
+
return {
|
|
322
|
+
status: "skipped",
|
|
323
|
+
message: "Selected provider does not require an API key.",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const normalizedProvider = options.provider.trim().toLowerCase();
|
|
328
|
+
if (!isSupportedLlmProvider(normalizedProvider)) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Provider '${options.provider}' does not have a supported API-key setup flow.`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const provider = normalizedProvider;
|
|
334
|
+
const providerName = formatProviderName(provider);
|
|
335
|
+
const envVarName = LLM_PROVIDER_ENV_VAR_NAMES[provider];
|
|
336
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
337
|
+
|
|
338
|
+
const existing = await readGatewayApiKey(
|
|
339
|
+
options.gatewayUrl,
|
|
340
|
+
provider,
|
|
341
|
+
options.bearerToken,
|
|
342
|
+
fetchImpl,
|
|
343
|
+
);
|
|
344
|
+
if (existing.unreachable) {
|
|
345
|
+
return {
|
|
346
|
+
status: "failed",
|
|
347
|
+
provider,
|
|
348
|
+
message:
|
|
349
|
+
"Assistant credential store is unavailable. Try again after the assistant finishes starting.",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (existing.found) {
|
|
353
|
+
return {
|
|
354
|
+
status: "already_configured",
|
|
355
|
+
provider,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const envValue = options.env?.[envVarName]?.trim();
|
|
360
|
+
let apiKey = envValue;
|
|
361
|
+
let source: ProviderApiKeySource = "env";
|
|
362
|
+
|
|
363
|
+
if (!apiKey) {
|
|
364
|
+
source = "prompt";
|
|
365
|
+
if (options.prompt) {
|
|
366
|
+
apiKey = (
|
|
367
|
+
await options.prompt(
|
|
368
|
+
`Enter your ${providerName} API key (${envVarName}): `,
|
|
369
|
+
)
|
|
370
|
+
).trim();
|
|
371
|
+
} else {
|
|
372
|
+
const stdinIsTTY = options.stdinIsTTY ?? process.stdin.isTTY;
|
|
373
|
+
if (!stdinIsTTY) {
|
|
374
|
+
return {
|
|
375
|
+
status: "missing",
|
|
376
|
+
provider,
|
|
377
|
+
message: `Missing ${envVarName}. Set it in the environment or run vellum setup from an interactive terminal.`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
apiKey = (
|
|
381
|
+
await promptSecret(
|
|
382
|
+
`Enter your ${providerName} API key (${envVarName}): `,
|
|
383
|
+
{
|
|
384
|
+
input: options.input,
|
|
385
|
+
output: options.output,
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
).trim();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!apiKey) {
|
|
393
|
+
return {
|
|
394
|
+
status: "missing",
|
|
395
|
+
provider,
|
|
396
|
+
message: "API key cannot be empty.",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await injectGatewayApiKey(
|
|
401
|
+
options.gatewayUrl,
|
|
402
|
+
provider,
|
|
403
|
+
apiKey,
|
|
404
|
+
options.bearerToken,
|
|
405
|
+
fetchImpl,
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
status: "configured",
|
|
410
|
+
provider,
|
|
411
|
+
source,
|
|
412
|
+
};
|
|
413
|
+
}
|
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,24 +38,25 @@ 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
|
-
log?.(
|
|
53
|
-
`Token found (${token.length} chars, prefix: ${token.slice(0, 6)}…)`,
|
|
54
|
-
);
|
|
59
|
+
log?.(`Token found (${token.length} chars, prefix: ${token.slice(0, 6)}…)`);
|
|
55
60
|
|
|
56
61
|
// Fetch user info for the login status line
|
|
57
62
|
let email: string | undefined;
|
|
@@ -87,27 +92,41 @@ export async function syncCloudAssistants(
|
|
|
87
92
|
const platformIds = new Set(platformAssistants.map((a) => a.id));
|
|
88
93
|
|
|
89
94
|
// Add new platform assistants not yet in the lockfile
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
const existingCloudEntries = loadAllAssistants().filter(
|
|
96
|
+
(a) => a.cloud === "vellum",
|
|
97
|
+
);
|
|
98
|
+
const existingCloudById = new Map(
|
|
99
|
+
existingCloudEntries.map((a) => [a.assistantId, a]),
|
|
94
100
|
);
|
|
101
|
+
const existingCloudIds = new Set(existingCloudById.keys());
|
|
95
102
|
log?.(
|
|
96
103
|
`Lockfile has ${existingCloudIds.size} cloud assistant(s): ${[...existingCloudIds].join(", ") || "(none)"}`,
|
|
97
104
|
);
|
|
98
105
|
|
|
99
106
|
let added = 0;
|
|
107
|
+
let updated = 0;
|
|
100
108
|
for (const pa of platformAssistants) {
|
|
101
|
-
|
|
109
|
+
const existing = existingCloudById.get(pa.id);
|
|
110
|
+
const assistantName = pa.name.trim();
|
|
111
|
+
const nameFields = assistantName ? { name: assistantName } : {};
|
|
112
|
+
if (!existing) {
|
|
102
113
|
log?.(`Adding ${pa.name || pa.id} to lockfile`);
|
|
103
114
|
saveAssistantEntry({
|
|
104
115
|
assistantId: pa.id,
|
|
116
|
+
...nameFields,
|
|
105
117
|
runtimeUrl: getPlatformUrl(),
|
|
106
118
|
cloud: "vellum",
|
|
107
119
|
species: "vellum",
|
|
108
120
|
hatchedAt: new Date().toISOString(),
|
|
109
121
|
});
|
|
110
122
|
added++;
|
|
123
|
+
} else if (assistantName && existing.name !== assistantName) {
|
|
124
|
+
log?.(`Updating ${pa.id} name to ${assistantName}`);
|
|
125
|
+
saveAssistantEntry({
|
|
126
|
+
...existing,
|
|
127
|
+
name: assistantName,
|
|
128
|
+
});
|
|
129
|
+
updated++;
|
|
111
130
|
}
|
|
112
131
|
}
|
|
113
132
|
|
|
@@ -121,6 +140,8 @@ export async function syncCloudAssistants(
|
|
|
121
140
|
}
|
|
122
141
|
}
|
|
123
142
|
|
|
124
|
-
log?.(
|
|
143
|
+
log?.(
|
|
144
|
+
`Sync complete: ${added} added, ${updated} updated, ${removed} removed`,
|
|
145
|
+
);
|
|
125
146
|
return { added, removed, email };
|
|
126
147
|
}
|