@vellumai/cli 0.6.4 → 0.6.6

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/src/lib/local.ts CHANGED
@@ -1076,6 +1076,7 @@ export async function startGateway(
1076
1076
  // (mirrors the daemon env setup).
1077
1077
  ...(resources
1078
1078
  ? {
1079
+ BASE_DATA_DIR: resources.instanceDir,
1079
1080
  VELLUM_WORKSPACE_DIR: join(
1080
1081
  resources.instanceDir,
1081
1082
  ".vellum",
@@ -222,6 +222,140 @@ export async function ensureSelfHostedLocalRegistration(
222
222
  return (await response.json()) as EnsureRegistrationResponse;
223
223
  }
224
224
 
225
+ // ---------------------------------------------------------------------------
226
+ // API key reprovisioning
227
+ // ---------------------------------------------------------------------------
228
+
229
+ export interface ReprovisionApiKeyResponse {
230
+ provisioning: {
231
+ assistant_api_key: string;
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Reprovision (rotate) the API key for a self-hosted local assistant.
237
+ *
238
+ * Calls `POST /v1/assistants/self-hosted-local/reprovision-api-key/`.
239
+ * Returns a fresh API key. The previous key is revoked server-side.
240
+ */
241
+ export async function reprovisionAssistantApiKey(
242
+ token: string,
243
+ organizationId: string,
244
+ clientInstallationId: string,
245
+ runtimeAssistantId: string,
246
+ clientPlatform: string,
247
+ assistantVersion?: string,
248
+ platformUrl?: string,
249
+ ): Promise<ReprovisionApiKeyResponse> {
250
+ const resolvedUrl = platformUrl || getPlatformUrl();
251
+ const body: Record<string, string> = {
252
+ client_installation_id: clientInstallationId,
253
+ runtime_assistant_id: runtimeAssistantId,
254
+ client_platform: clientPlatform,
255
+ };
256
+ if (assistantVersion) {
257
+ body.assistant_version = assistantVersion;
258
+ }
259
+
260
+ const response = await fetch(
261
+ `${resolvedUrl}/v1/assistants/self-hosted-local/reprovision-api-key/`,
262
+ {
263
+ method: "POST",
264
+ headers: {
265
+ "Content-Type": "application/json",
266
+ Accept: "application/json",
267
+ "X-Session-Token": token,
268
+ "Vellum-Organization-Id": organizationId,
269
+ },
270
+ body: JSON.stringify(body),
271
+ },
272
+ );
273
+
274
+ if (response.status === 401 || response.status === 403) {
275
+ throw new Error("Authentication required for API key reprovisioning.");
276
+ }
277
+
278
+ if (!response.ok) {
279
+ const detail = await response.text().catch(() => "");
280
+ throw new Error(
281
+ `API key reprovisioning failed (${response.status}): ${detail || response.statusText}`,
282
+ );
283
+ }
284
+
285
+ return (await response.json()) as ReprovisionApiKeyResponse;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Credential reading from running assistant via gateway
290
+ // ---------------------------------------------------------------------------
291
+
292
+ export interface GatewayCredentialResult {
293
+ /** The credential value, if found. */
294
+ value: string | null;
295
+ /** True when the gateway/daemon was unreachable (network error, timeout, etc.). */
296
+ unreachable: boolean;
297
+ }
298
+
299
+ /**
300
+ * Read an existing credential from the assistant's secret store via the
301
+ * gateway-proxied `POST /v1/secrets/read` endpoint (with `reveal: true`).
302
+ *
303
+ * Returns a result distinguishing "key not found" (`value: null,
304
+ * unreachable: false`) from "gateway unreachable" (`value: null,
305
+ * unreachable: true`). Callers should only reprovision when the gateway
306
+ * is reachable but the key is genuinely missing — reprovisioning while
307
+ * the gateway is down would revoke the old key server-side without being
308
+ * able to inject the replacement.
309
+ *
310
+ * Never throws.
311
+ */
312
+ export async function readGatewayCredential(
313
+ gatewayUrl: string,
314
+ name: string,
315
+ bearerToken?: string,
316
+ ): Promise<GatewayCredentialResult> {
317
+ try {
318
+ const headers: Record<string, string> = {
319
+ "Content-Type": "application/json",
320
+ Accept: "application/json",
321
+ };
322
+ if (bearerToken) {
323
+ headers["Authorization"] = `Bearer ${bearerToken}`;
324
+ }
325
+
326
+ const response = await fetch(`${gatewayUrl}/v1/secrets/read`, {
327
+ method: "POST",
328
+ headers,
329
+ body: JSON.stringify({ type: "credential", name, reveal: true }),
330
+ signal: AbortSignal.timeout(10_000),
331
+ });
332
+
333
+ if (!response.ok) {
334
+ // 5xx means the gateway/daemon backend is down — treat as unreachable
335
+ // so callers don't revoke a potentially valid key.
336
+ return { value: null, unreachable: response.status >= 500 };
337
+ }
338
+
339
+ const json = (await response.json()) as {
340
+ found: boolean;
341
+ value?: string;
342
+ unreachable?: boolean;
343
+ };
344
+ // The daemon's /v1/secrets/read returns `unreachable: true` when the
345
+ // credential backend (CES) can't be reached. Respect that signal.
346
+ if (json.unreachable) {
347
+ return { value: null, unreachable: true };
348
+ }
349
+ return {
350
+ value: json.found && json.value ? json.value : null,
351
+ unreachable: false,
352
+ };
353
+ } catch {
354
+ // Network error, timeout, or gateway down
355
+ return { value: null, unreachable: true };
356
+ }
357
+ }
358
+
225
359
  // ---------------------------------------------------------------------------
226
360
  // Credential injection into running assistant via gateway
227
361
  // ---------------------------------------------------------------------------
@@ -1,13 +1,17 @@
1
1
  import { createConnection } from "net";
2
2
  import { existsSync } from "fs";
3
3
 
4
- import type { AssistantEntry } from "../lib/assistant-config";
4
+ import type { AssistantEntry } from "./assistant-config";
5
5
 
6
6
  /**
7
7
  * Connect to an Apple Container assistant via its management socket.
8
8
  * Sends a JSON handshake then relays stdin/stdout in raw mode.
9
9
  */
10
- export async function sshAppleContainer(entry: AssistantEntry): Promise<void> {
10
+ export async function sshAppleContainer(
11
+ entry: AssistantEntry,
12
+ command?: string[],
13
+ service?: string,
14
+ ): Promise<void> {
11
15
  const mgmtSocket = entry.mgmtSocket as string | undefined;
12
16
  if (!mgmtSocket) {
13
17
  console.error(
@@ -34,8 +38,8 @@ export async function sshAppleContainer(entry: AssistantEntry): Promise<void> {
34
38
 
35
39
  const handshake =
36
40
  JSON.stringify({
37
- command: ["/bin/bash"],
38
- service: "vellum-assistant",
41
+ command: command && command.length > 0 ? command : ["/bin/bash"],
42
+ service: service || "vellum-assistant",
39
43
  cols,
40
44
  rows,
41
45
  }) + "\n";
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Platform terminal API client.
3
+ *
4
+ * Wraps the Django terminal session endpoints that proxy through vembda to
5
+ * open K8s exec streams into managed assistant containers. Same transport
6
+ * the web UI's xterm.js terminal uses.
7
+ */
8
+
9
+ import { authHeaders, getPlatformUrl } from "./platform-client.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Create / Close
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export async function createTerminalSession(
16
+ token: string,
17
+ assistantId: string,
18
+ cols: number,
19
+ rows: number,
20
+ platformUrl?: string,
21
+ ): Promise<{ session_id: string }> {
22
+ const baseUrl = platformUrl || getPlatformUrl();
23
+ const response = await fetch(
24
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/`,
25
+ {
26
+ method: "POST",
27
+ headers: await authHeaders(token, platformUrl),
28
+ body: JSON.stringify({ cols, rows }),
29
+ },
30
+ );
31
+ if (!response.ok) {
32
+ const detail = await response.text().catch(() => "");
33
+ throw new Error(
34
+ `Failed to create terminal session (${response.status}): ${detail || response.statusText}`,
35
+ );
36
+ }
37
+ return (await response.json()) as { session_id: string };
38
+ }
39
+
40
+ export async function closeTerminalSession(
41
+ token: string,
42
+ assistantId: string,
43
+ sessionId: string,
44
+ platformUrl?: string,
45
+ ): Promise<void> {
46
+ const baseUrl = platformUrl || getPlatformUrl();
47
+ const response = await fetch(
48
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/`,
49
+ {
50
+ method: "DELETE",
51
+ headers: await authHeaders(token, platformUrl),
52
+ },
53
+ );
54
+ // 404 = already closed, treat as success
55
+ if (!response.ok && response.status !== 404) {
56
+ throw new Error(
57
+ `Failed to close terminal session (${response.status}): ${response.statusText}`,
58
+ );
59
+ }
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Input / Resize
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export async function sendTerminalInput(
67
+ token: string,
68
+ assistantId: string,
69
+ sessionId: string,
70
+ data: string,
71
+ platformUrl?: string,
72
+ ): Promise<void> {
73
+ const baseUrl = platformUrl || getPlatformUrl();
74
+ const response = await fetch(
75
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/input/`,
76
+ {
77
+ method: "POST",
78
+ headers: await authHeaders(token, platformUrl),
79
+ body: JSON.stringify({ data }),
80
+ },
81
+ );
82
+ if (!response.ok) {
83
+ throw new Error(
84
+ `Failed to send terminal input (${response.status}): ${response.statusText}`,
85
+ );
86
+ }
87
+ }
88
+
89
+ export async function resizeTerminalSession(
90
+ token: string,
91
+ assistantId: string,
92
+ sessionId: string,
93
+ cols: number,
94
+ rows: number,
95
+ platformUrl?: string,
96
+ ): Promise<void> {
97
+ const baseUrl = platformUrl || getPlatformUrl();
98
+ const response = await fetch(
99
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/resize/`,
100
+ {
101
+ method: "POST",
102
+ headers: await authHeaders(token, platformUrl),
103
+ body: JSON.stringify({ cols, rows }),
104
+ },
105
+ );
106
+ if (!response.ok) {
107
+ throw new Error(
108
+ `Failed to resize terminal (${response.status}): ${response.statusText}`,
109
+ );
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // SSE event stream
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export interface TerminalOutputEvent {
118
+ seq: number;
119
+ /** Base64-encoded PTY output bytes. */
120
+ data: string;
121
+ }
122
+
123
+ /**
124
+ * Subscribe to the terminal output SSE stream. Yields parsed events as they
125
+ * arrive. The generator completes when the stream ends or is aborted.
126
+ */
127
+ export async function* subscribeTerminalEvents(
128
+ token: string,
129
+ assistantId: string,
130
+ sessionId: string,
131
+ platformUrl?: string,
132
+ signal?: AbortSignal,
133
+ ): AsyncGenerator<TerminalOutputEvent> {
134
+ const baseUrl = platformUrl || getPlatformUrl();
135
+ const response = await fetch(
136
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/events/`,
137
+ {
138
+ headers: await authHeaders(token, platformUrl),
139
+ signal,
140
+ },
141
+ );
142
+
143
+ if (!response.ok || !response.body) {
144
+ throw new Error(
145
+ `SSE connection failed (${response.status}): ${response.statusText}`,
146
+ );
147
+ }
148
+
149
+ const reader = response.body.getReader();
150
+ const decoder = new TextDecoder();
151
+ let buffer = "";
152
+
153
+ try {
154
+ while (true) {
155
+ const { done, value } = await reader.read();
156
+ if (done) break;
157
+
158
+ buffer += decoder.decode(value, { stream: true });
159
+ const lines = buffer.split("\n");
160
+ // Keep the last incomplete line in the buffer
161
+ buffer = lines.pop() ?? "";
162
+
163
+ for (const line of lines) {
164
+ const trimmed = line.trimEnd();
165
+ if (trimmed.startsWith("data: ")) {
166
+ try {
167
+ yield JSON.parse(trimmed.slice(6)) as TerminalOutputEvent;
168
+ } catch {
169
+ // Skip malformed SSE frames
170
+ }
171
+ }
172
+ }
173
+ }
174
+ } finally {
175
+ reader.releaseLock();
176
+ }
177
+ }
@@ -1,19 +1,43 @@
1
1
  /**
2
2
  * Provider API key environment variable names, keyed by provider ID.
3
3
  *
4
- * Keep in sync with:
5
- * - assistant/src/shared/provider-env-vars.ts
6
- * - meta/provider-env-vars.json (consumed by the macOS client build)
4
+ * Two sources are merged into a single combined map:
7
5
  *
8
- * Once a consolidated shared package exists in packages/, all three
9
- * copies can be replaced by a single import.
6
+ * 1. Search-provider env vars sourced from `meta/provider-env-vars.json`
7
+ * (single source of truth, also bundled into the macOS client).
8
+ * 2. LLM-provider env vars — sourced from `PROVIDER_CATALOG` in
9
+ * `assistant/src/providers/model-catalog.ts` via a locally-maintained
10
+ * mirror (the CLI does not import from `assistant/src/`; drift is caught
11
+ * by `cli/src/__tests__/llm-provider-env-var-parity.test.ts`).
12
+ *
13
+ * The combined map is what cloud-infra code (docker.ts, aws.ts, gcp.ts)
14
+ * iterates to forward provider API keys from the caller's environment into
15
+ * containers / VMs. Keeping both kinds of provider env vars in one map means
16
+ * the infra call sites don't need to know which kind is which — they just
17
+ * forward every value whose env var is set.
10
18
  */
11
- export const PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
19
+
20
+ /** LLM provider env var names. Mirrors `PROVIDER_CATALOG` entries with an `envVar`. */
21
+ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
12
22
  anthropic: "ANTHROPIC_API_KEY",
13
23
  openai: "OPENAI_API_KEY",
14
24
  gemini: "GEMINI_API_KEY",
15
25
  fireworks: "FIREWORKS_API_KEY",
16
26
  openrouter: "OPENROUTER_API_KEY",
27
+ };
28
+
29
+ /** Search-provider env var names. Mirrors `meta/provider-env-vars.json`. */
30
+ export const SEARCH_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
17
31
  brave: "BRAVE_API_KEY",
18
32
  perplexity: "PERPLEXITY_API_KEY",
19
33
  };
34
+
35
+ /**
36
+ * Combined provider env var names — the union of LLM and search providers.
37
+ * Used by the cloud-infra flows (docker/aws/gcp) to forward every supported
38
+ * provider API key from the caller's environment.
39
+ */
40
+ export const PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
41
+ ...LLM_PROVIDER_ENV_VAR_NAMES,
42
+ ...SEARCH_PROVIDER_ENV_VAR_NAMES,
43
+ };