@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/bun.lock +17 -17
- package/package.json +18 -18
- package/src/__tests__/guardian-token.test.ts +56 -3
- package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
- package/src/__tests__/multi-local.test.ts +30 -0
- package/src/commands/exec.ts +186 -0
- package/src/commands/login.ts +32 -1
- package/src/commands/retire.ts +23 -0
- package/src/commands/ssh.ts +1 -1
- package/src/commands/teleport.ts +28 -1
- package/src/commands/terminal.ts +437 -0
- package/src/commands/wake.ts +11 -0
- package/src/index.ts +6 -0
- package/src/lib/__tests__/docker.test.ts +91 -1
- package/src/lib/assistant-config.ts +35 -22
- package/src/lib/config-utils.ts +4 -4
- package/src/lib/docker.ts +88 -4
- package/src/lib/environments/__tests__/paths.test.ts +3 -9
- package/src/lib/environments/__tests__/seeds.test.ts +72 -0
- package/src/lib/environments/paths.ts +4 -5
- package/src/lib/environments/seeds.ts +29 -1
- package/src/lib/exec-apple-container.ts +122 -0
- package/src/lib/guardian-token.ts +63 -0
- package/src/lib/hatch-local.ts +20 -4
- package/src/lib/local.ts +1 -0
- package/src/lib/platform-client.ts +134 -0
- package/src/{commands → lib}/ssh-apple-container.ts +8 -4
- package/src/lib/terminal-client.ts +177 -0
- package/src/shared/provider-env-vars.ts +30 -6
package/src/lib/local.ts
CHANGED
|
@@ -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 "
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
+
};
|