@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
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vellum terminal` — Interactive shell into a managed assistant container.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the local tty to a platform terminal session (K8s exec) so the
|
|
5
|
+
* user can interact with their assistant's sandbox from iTerm2 or any
|
|
6
|
+
* local terminal emulator.
|
|
7
|
+
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
* vellum terminal — Interactive shell
|
|
10
|
+
* vellum terminal attach <name> — Attach to a tmux session
|
|
11
|
+
* vellum terminal list — List tmux sessions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
findAssistantByName,
|
|
16
|
+
loadLatestAssistant,
|
|
17
|
+
resolveCloud,
|
|
18
|
+
} from "../lib/assistant-config.js";
|
|
19
|
+
import { getPlatformUrl, readPlatformToken } from "../lib/platform-client.js";
|
|
20
|
+
import {
|
|
21
|
+
closeTerminalSession,
|
|
22
|
+
createTerminalSession,
|
|
23
|
+
resizeTerminalSession,
|
|
24
|
+
sendTerminalInput,
|
|
25
|
+
subscribeTerminalEvents,
|
|
26
|
+
} from "../lib/terminal-client.js";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function printHelp(): void {
|
|
33
|
+
console.log("Usage: vellum terminal [subcommand] [options]");
|
|
34
|
+
console.log("");
|
|
35
|
+
console.log(
|
|
36
|
+
"Open an interactive terminal session into a managed assistant container.",
|
|
37
|
+
);
|
|
38
|
+
console.log("");
|
|
39
|
+
console.log("Subcommands:");
|
|
40
|
+
console.log(" (none) Interactive shell");
|
|
41
|
+
console.log(
|
|
42
|
+
" attach <name> Attach to a tmux session inside the container",
|
|
43
|
+
);
|
|
44
|
+
console.log(
|
|
45
|
+
" list List tmux sessions running inside the container",
|
|
46
|
+
);
|
|
47
|
+
console.log("");
|
|
48
|
+
console.log("Options:");
|
|
49
|
+
console.log(
|
|
50
|
+
" <name> Name of the assistant (defaults to active)",
|
|
51
|
+
);
|
|
52
|
+
console.log(
|
|
53
|
+
" --assistant <name> Explicit assistant name (alternative to positional)",
|
|
54
|
+
);
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log("Examples:");
|
|
57
|
+
console.log(" vellum terminal");
|
|
58
|
+
console.log(" vellum terminal attach my-session");
|
|
59
|
+
console.log(" vellum terminal list");
|
|
60
|
+
console.log(" vellum terminal --assistant my-assistant");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ResolvedAssistant {
|
|
64
|
+
assistantId: string;
|
|
65
|
+
token: string;
|
|
66
|
+
platformUrl: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveAssistant(nameArg?: string): ResolvedAssistant {
|
|
70
|
+
const entry = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
|
|
71
|
+
|
|
72
|
+
if (!entry) {
|
|
73
|
+
if (nameArg) {
|
|
74
|
+
console.error(`No assistant instance found with name '${nameArg}'.`);
|
|
75
|
+
} else {
|
|
76
|
+
console.error("No assistant instance found. Run `vellum hatch` first.");
|
|
77
|
+
}
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cloud = resolveCloud(entry);
|
|
82
|
+
if (cloud !== "vellum") {
|
|
83
|
+
if (cloud === "local") {
|
|
84
|
+
console.error(
|
|
85
|
+
"This assistant runs locally on your machine. You can access it directly.",
|
|
86
|
+
);
|
|
87
|
+
} else if (cloud === "docker") {
|
|
88
|
+
console.error(
|
|
89
|
+
`Use 'vellum exec -it -- /bin/bash' or 'vellum ssh' for ${cloud} instances.`,
|
|
90
|
+
);
|
|
91
|
+
} else {
|
|
92
|
+
console.error(
|
|
93
|
+
`'vellum terminal' is for managed (cloud-hosted) assistants. This assistant uses '${cloud}'.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const token = readPlatformToken();
|
|
100
|
+
if (!token) {
|
|
101
|
+
console.error(
|
|
102
|
+
"Not logged in. Run `vellum login` first to authenticate with the platform.",
|
|
103
|
+
);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
assistantId: entry.assistantId,
|
|
109
|
+
token,
|
|
110
|
+
platformUrl: getPlatformUrl(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Interactive session
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
async function interactiveSession(
|
|
119
|
+
assistant: ResolvedAssistant,
|
|
120
|
+
initialCommand?: string,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const cols = process.stdout.columns || 80;
|
|
123
|
+
const rows = process.stdout.rows || 24;
|
|
124
|
+
|
|
125
|
+
console.error(`\x1b[2m🔗 Connecting to ${assistant.assistantId}...\x1b[0m`);
|
|
126
|
+
|
|
127
|
+
const { session_id: sessionId } = await createTerminalSession(
|
|
128
|
+
assistant.token,
|
|
129
|
+
assistant.assistantId,
|
|
130
|
+
cols,
|
|
131
|
+
rows,
|
|
132
|
+
assistant.platformUrl,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// --- TTY raw mode setup ---
|
|
136
|
+
const wasRaw = process.stdin.isRaw;
|
|
137
|
+
if (process.stdin.isTTY) {
|
|
138
|
+
process.stdin.setRawMode(true);
|
|
139
|
+
}
|
|
140
|
+
process.stdin.resume();
|
|
141
|
+
process.stdin.setEncoding("utf-8");
|
|
142
|
+
|
|
143
|
+
// Abort controller for the SSE stream
|
|
144
|
+
const abortController = new AbortController();
|
|
145
|
+
let exiting = false;
|
|
146
|
+
|
|
147
|
+
// --- Cleanup function (idempotent) ---
|
|
148
|
+
async function cleanup(): Promise<void> {
|
|
149
|
+
if (exiting) return;
|
|
150
|
+
exiting = true;
|
|
151
|
+
|
|
152
|
+
// Restore tty
|
|
153
|
+
if (process.stdin.isTTY) {
|
|
154
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
155
|
+
}
|
|
156
|
+
process.stdin.pause();
|
|
157
|
+
|
|
158
|
+
// Abort SSE stream
|
|
159
|
+
abortController.abort();
|
|
160
|
+
|
|
161
|
+
// Close remote session (best-effort)
|
|
162
|
+
try {
|
|
163
|
+
await closeTerminalSession(
|
|
164
|
+
assistant.token,
|
|
165
|
+
assistant.assistantId,
|
|
166
|
+
sessionId,
|
|
167
|
+
assistant.platformUrl,
|
|
168
|
+
);
|
|
169
|
+
} catch {
|
|
170
|
+
// Best-effort cleanup
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- Signal handlers ---
|
|
175
|
+
const onSigInt = () => {
|
|
176
|
+
cleanup().then(() => process.exit(0));
|
|
177
|
+
};
|
|
178
|
+
const onSigTerm = () => {
|
|
179
|
+
cleanup().then(() => process.exit(0));
|
|
180
|
+
};
|
|
181
|
+
process.on("SIGINT", onSigInt);
|
|
182
|
+
process.on("SIGTERM", onSigTerm);
|
|
183
|
+
|
|
184
|
+
// --- SIGWINCH (terminal resize) ---
|
|
185
|
+
const onResize = () => {
|
|
186
|
+
const newCols = process.stdout.columns || 80;
|
|
187
|
+
const newRows = process.stdout.rows || 24;
|
|
188
|
+
resizeTerminalSession(
|
|
189
|
+
assistant.token,
|
|
190
|
+
assistant.assistantId,
|
|
191
|
+
sessionId,
|
|
192
|
+
newCols,
|
|
193
|
+
newRows,
|
|
194
|
+
assistant.platformUrl,
|
|
195
|
+
).catch(() => {
|
|
196
|
+
// Resize failures are non-fatal
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
process.stdout.on("resize", onResize);
|
|
200
|
+
|
|
201
|
+
// --- Input: stdin → remote ---
|
|
202
|
+
let inputBuffer = "";
|
|
203
|
+
let inputTimer: ReturnType<typeof setTimeout> | null = null;
|
|
204
|
+
const INPUT_DEBOUNCE_MS = 30;
|
|
205
|
+
|
|
206
|
+
function flushInput(): void {
|
|
207
|
+
if (inputBuffer.length === 0) return;
|
|
208
|
+
const data = inputBuffer;
|
|
209
|
+
inputBuffer = "";
|
|
210
|
+
sendTerminalInput(
|
|
211
|
+
assistant.token,
|
|
212
|
+
assistant.assistantId,
|
|
213
|
+
sessionId,
|
|
214
|
+
data,
|
|
215
|
+
assistant.platformUrl,
|
|
216
|
+
).catch((err) => {
|
|
217
|
+
if (!exiting) {
|
|
218
|
+
console.error(`\r\nInput error: ${err.message}\r\n`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
process.stdin.on("data", (chunk: string) => {
|
|
224
|
+
if (exiting) return;
|
|
225
|
+
inputBuffer += chunk;
|
|
226
|
+
if (inputTimer) clearTimeout(inputTimer);
|
|
227
|
+
inputTimer = setTimeout(flushInput, INPUT_DEBOUNCE_MS);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// --- Send initial command (for `attach` subcommand) ---
|
|
231
|
+
if (initialCommand) {
|
|
232
|
+
// Brief delay to let the shell initialize
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
234
|
+
await sendTerminalInput(
|
|
235
|
+
assistant.token,
|
|
236
|
+
assistant.assistantId,
|
|
237
|
+
sessionId,
|
|
238
|
+
initialCommand + "\r",
|
|
239
|
+
assistant.platformUrl,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Output: remote SSE → stdout ---
|
|
244
|
+
try {
|
|
245
|
+
for await (const event of subscribeTerminalEvents(
|
|
246
|
+
assistant.token,
|
|
247
|
+
assistant.assistantId,
|
|
248
|
+
sessionId,
|
|
249
|
+
assistant.platformUrl,
|
|
250
|
+
abortController.signal,
|
|
251
|
+
)) {
|
|
252
|
+
if (exiting) break;
|
|
253
|
+
// Decode base64 output and write raw bytes to stdout
|
|
254
|
+
const bytes = Buffer.from(event.data, "base64");
|
|
255
|
+
process.stdout.write(bytes);
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (!exiting) {
|
|
259
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
260
|
+
// AbortError is expected on cleanup
|
|
261
|
+
if (!msg.includes("abort")) {
|
|
262
|
+
console.error(`\r\nConnection lost: ${msg}\r\n`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
await cleanup();
|
|
267
|
+
|
|
268
|
+
// Remove listeners
|
|
269
|
+
process.off("SIGINT", onSigInt);
|
|
270
|
+
process.off("SIGTERM", onSigTerm);
|
|
271
|
+
process.stdout.off("resize", onResize);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// List tmux sessions
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
async function listTmuxSessions(assistant: ResolvedAssistant): Promise<void> {
|
|
280
|
+
const cols = 120;
|
|
281
|
+
const rows = 24;
|
|
282
|
+
|
|
283
|
+
const { session_id: sessionId } = await createTerminalSession(
|
|
284
|
+
assistant.token,
|
|
285
|
+
assistant.assistantId,
|
|
286
|
+
cols,
|
|
287
|
+
rows,
|
|
288
|
+
assistant.platformUrl,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const abortController = new AbortController();
|
|
292
|
+
const output: string[] = [];
|
|
293
|
+
let commandSent = false;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const timeout = setTimeout(() => abortController.abort(), 5000);
|
|
297
|
+
|
|
298
|
+
const streamPromise = (async () => {
|
|
299
|
+
for await (const event of subscribeTerminalEvents(
|
|
300
|
+
assistant.token,
|
|
301
|
+
assistant.assistantId,
|
|
302
|
+
sessionId,
|
|
303
|
+
assistant.platformUrl,
|
|
304
|
+
abortController.signal,
|
|
305
|
+
)) {
|
|
306
|
+
const text = Buffer.from(event.data, "base64").toString("utf-8");
|
|
307
|
+
output.push(text);
|
|
308
|
+
|
|
309
|
+
// Wait for shell prompt before sending command
|
|
310
|
+
if (!commandSent) {
|
|
311
|
+
const joined = output.join("");
|
|
312
|
+
if (
|
|
313
|
+
joined.includes("$") ||
|
|
314
|
+
joined.includes("#") ||
|
|
315
|
+
joined.includes("%")
|
|
316
|
+
) {
|
|
317
|
+
commandSent = true;
|
|
318
|
+
await sendTerminalInput(
|
|
319
|
+
assistant.token,
|
|
320
|
+
assistant.assistantId,
|
|
321
|
+
sessionId,
|
|
322
|
+
'tmux list-sessions 2>/dev/null || echo "No tmux sessions found"; exit\r',
|
|
323
|
+
assistant.platformUrl,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
|
|
330
|
+
await streamPromise.catch(() => {});
|
|
331
|
+
clearTimeout(timeout);
|
|
332
|
+
} catch {
|
|
333
|
+
// Expected — abort or stream end
|
|
334
|
+
} finally {
|
|
335
|
+
abortController.abort();
|
|
336
|
+
await closeTerminalSession(
|
|
337
|
+
assistant.token,
|
|
338
|
+
assistant.assistantId,
|
|
339
|
+
sessionId,
|
|
340
|
+
assistant.platformUrl,
|
|
341
|
+
).catch(() => {});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Parse and display results
|
|
345
|
+
const raw = output.join("");
|
|
346
|
+
// Strip ANSI escape sequences for clean parsing
|
|
347
|
+
const clean = raw.replace(
|
|
348
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
|
|
349
|
+
/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][^\n]|\r/g,
|
|
350
|
+
"",
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Find tmux output lines (format: "session_name: N windows ...")
|
|
354
|
+
const lines = clean.split("\n");
|
|
355
|
+
const sessionLines = lines.filter(
|
|
356
|
+
(l) =>
|
|
357
|
+
/^\S+:\s+\d+\s+windows?/.test(l.trim()) ||
|
|
358
|
+
l.includes("No tmux sessions found"),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
if (sessionLines.length === 0) {
|
|
362
|
+
console.log("No tmux sessions found.");
|
|
363
|
+
} else {
|
|
364
|
+
for (const line of sessionLines) {
|
|
365
|
+
console.log(line.trim());
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Main entry point
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
export async function terminal(): Promise<void> {
|
|
375
|
+
const args = process.argv.slice(3);
|
|
376
|
+
|
|
377
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
378
|
+
printHelp();
|
|
379
|
+
process.exit(0);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Parse arguments
|
|
383
|
+
//
|
|
384
|
+
// Accepted forms:
|
|
385
|
+
// vellum terminal [--assistant <name>]
|
|
386
|
+
// vellum terminal list [--assistant <name>]
|
|
387
|
+
// vellum terminal attach <session> [--assistant <name>]
|
|
388
|
+
let subcommand: string | undefined;
|
|
389
|
+
let assistantName: string | undefined;
|
|
390
|
+
let tmuxSessionName: string | undefined;
|
|
391
|
+
|
|
392
|
+
for (let i = 0; i < args.length; i++) {
|
|
393
|
+
if (args[i] === "--assistant" && args[i + 1]) {
|
|
394
|
+
assistantName = args[++i];
|
|
395
|
+
} else if (args[i].startsWith("-")) {
|
|
396
|
+
// Skip unknown flags
|
|
397
|
+
continue;
|
|
398
|
+
} else if (!subcommand) {
|
|
399
|
+
// First positional — subcommand or assistant name
|
|
400
|
+
if (args[i] === "list" || args[i] === "attach") {
|
|
401
|
+
subcommand = args[i];
|
|
402
|
+
} else {
|
|
403
|
+
assistantName = args[i];
|
|
404
|
+
}
|
|
405
|
+
} else if (subcommand === "attach" && !tmuxSessionName) {
|
|
406
|
+
// Second positional after "attach" — tmux session name
|
|
407
|
+
tmuxSessionName = args[i];
|
|
408
|
+
} else if (!assistantName) {
|
|
409
|
+
// Trailing positional after subcommand args — assistant name
|
|
410
|
+
assistantName = args[i];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const assistant = resolveAssistant(assistantName);
|
|
415
|
+
|
|
416
|
+
if (subcommand === "list") {
|
|
417
|
+
await listTmuxSessions(assistant);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (subcommand === "attach") {
|
|
422
|
+
if (!tmuxSessionName) {
|
|
423
|
+
console.error("Usage: vellum terminal attach <session-name>");
|
|
424
|
+
console.error(
|
|
425
|
+
"\nUse 'vellum terminal list' to see available tmux sessions.",
|
|
426
|
+
);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
// Shell-escape the session name to handle spaces/metacharacters
|
|
430
|
+
const escaped = tmuxSessionName.replace(/'/g, "'\\''");
|
|
431
|
+
await interactiveSession(assistant, `tmux attach -t '${escaped}'`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Default: interactive shell
|
|
436
|
+
await interactiveSession(assistant);
|
|
437
|
+
}
|
package/src/commands/wake.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
saveAssistantEntry,
|
|
7
7
|
} from "../lib/assistant-config.js";
|
|
8
8
|
import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
|
|
9
|
+
import { seedGuardianTokenFromSiblingEnv } from "../lib/guardian-token.js";
|
|
9
10
|
import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
|
|
10
11
|
import {
|
|
11
12
|
generateLocalSigningKey,
|
|
@@ -182,6 +183,16 @@ export async function wake(): Promise<void> {
|
|
|
182
183
|
}
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
// Self-heal the guardian token when the current environment's config dir
|
|
187
|
+
// is missing it. Hatch cross-writes the lockfile across env dirs but the
|
|
188
|
+
// guardian token is only persisted under the hatch-time env, so a desktop
|
|
189
|
+
// app built under a different VELLUM_ENVIRONMENT can't find a bearer and
|
|
190
|
+
// cascades into 401 → auth-rate-limit → 429. A sibling env copy is cheap
|
|
191
|
+
// and strictly additive.
|
|
192
|
+
if (seedGuardianTokenFromSiblingEnv(entry.assistantId)) {
|
|
193
|
+
console.log(" Seeded guardian token from sibling environment.");
|
|
194
|
+
}
|
|
195
|
+
|
|
185
196
|
// Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
|
|
186
197
|
// Scope BASE_DATA_DIR to the woken instance so ngrok reads the correct
|
|
187
198
|
// instance config, then restore on any exit path.
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { backup } from "./commands/backup";
|
|
|
5
5
|
import { clean } from "./commands/clean";
|
|
6
6
|
import { client } from "./commands/client";
|
|
7
7
|
import { events } from "./commands/events";
|
|
8
|
+
import { exec } from "./commands/exec";
|
|
8
9
|
import { hatch } from "./commands/hatch";
|
|
9
10
|
import { login, logout, whoami } from "./commands/login";
|
|
10
11
|
import { logs } from "./commands/logs";
|
|
@@ -19,6 +20,7 @@ import { setup } from "./commands/setup";
|
|
|
19
20
|
import { sleep } from "./commands/sleep";
|
|
20
21
|
import { ssh } from "./commands/ssh";
|
|
21
22
|
import { teleport } from "./commands/teleport";
|
|
23
|
+
import { terminal } from "./commands/terminal";
|
|
22
24
|
import { tunnel } from "./commands/tunnel";
|
|
23
25
|
import { upgrade } from "./commands/upgrade";
|
|
24
26
|
import { use } from "./commands/use";
|
|
@@ -37,6 +39,7 @@ const commands = {
|
|
|
37
39
|
clean,
|
|
38
40
|
client,
|
|
39
41
|
events,
|
|
42
|
+
exec,
|
|
40
43
|
hatch,
|
|
41
44
|
login,
|
|
42
45
|
logout,
|
|
@@ -52,6 +55,7 @@ const commands = {
|
|
|
52
55
|
sleep,
|
|
53
56
|
ssh,
|
|
54
57
|
teleport,
|
|
58
|
+
terminal,
|
|
55
59
|
tunnel,
|
|
56
60
|
upgrade,
|
|
57
61
|
use,
|
|
@@ -69,6 +73,7 @@ function printHelp(): void {
|
|
|
69
73
|
console.log(" clean Kill orphaned vellum processes");
|
|
70
74
|
console.log(" client Connect to a hatched assistant");
|
|
71
75
|
console.log(" events Stream events from a running assistant");
|
|
76
|
+
console.log(" exec Execute a command inside an assistant's container");
|
|
72
77
|
console.log(" hatch Create a new assistant instance");
|
|
73
78
|
console.log(" logs View logs from an assistant instance");
|
|
74
79
|
console.log(" login Log in to the Vellum platform");
|
|
@@ -88,6 +93,7 @@ function printHelp(): void {
|
|
|
88
93
|
console.log(" sleep Stop the assistant process");
|
|
89
94
|
console.log(" ssh SSH into a remote assistant instance");
|
|
90
95
|
console.log(" teleport Transfer assistant data between environments");
|
|
96
|
+
console.log(" terminal Open a terminal into a managed assistant container");
|
|
91
97
|
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
92
98
|
console.log(" upgrade Upgrade an assistant to a newer version");
|
|
93
99
|
console.log(" use Set the active assistant for commands");
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, test, expect } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
ASSISTANT_INTERNAL_PORT,
|
|
4
|
+
DEFAULT_MEET_AVATAR_DEVICE_PATH,
|
|
4
5
|
dockerResourceNames,
|
|
6
|
+
MEET_AVATAR_DEVICE_ENV_VAR,
|
|
7
|
+
MEET_AVATAR_ENV_VAR,
|
|
8
|
+
resolveMeetAvatarDevicePath,
|
|
5
9
|
serviceDockerRunArgs,
|
|
6
10
|
type ServiceName,
|
|
7
11
|
} from "../docker.js";
|
|
@@ -76,3 +80,89 @@ describe("serviceDockerRunArgs — assistant", () => {
|
|
|
76
80
|
expect(args[portIndex - 1]).toBe("-p");
|
|
77
81
|
});
|
|
78
82
|
});
|
|
83
|
+
|
|
84
|
+
describe("Meet avatar device passthrough (VELLUM_MEET_AVATAR opt-in)", () => {
|
|
85
|
+
// Snapshot + restore the process env so tests can flip the env-var
|
|
86
|
+
// without leaking state to later suites or other CLI tests.
|
|
87
|
+
const originalEnv: Record<string, string | undefined> = {};
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
for (const key of [MEET_AVATAR_ENV_VAR, MEET_AVATAR_DEVICE_ENV_VAR]) {
|
|
91
|
+
originalEnv[key] = process.env[key];
|
|
92
|
+
delete process.env[key];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
98
|
+
if (value === undefined) delete process.env[key];
|
|
99
|
+
else process.env[key] = value;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("resolveMeetAvatarDevicePath returns null when the env var is unset", () => {
|
|
104
|
+
expect(resolveMeetAvatarDevicePath({})).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("resolveMeetAvatarDevicePath treats 0/false/no as disabled", () => {
|
|
108
|
+
for (const value of ["", "0", "false", "FALSE", "no", " NO "]) {
|
|
109
|
+
expect(resolveMeetAvatarDevicePath({ [MEET_AVATAR_ENV_VAR]: value })).toBe(
|
|
110
|
+
null,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("resolveMeetAvatarDevicePath returns the default device path when enabled with a truthy value", () => {
|
|
116
|
+
for (const value of ["1", "true", "YES"]) {
|
|
117
|
+
expect(resolveMeetAvatarDevicePath({ [MEET_AVATAR_ENV_VAR]: value })).toBe(
|
|
118
|
+
DEFAULT_MEET_AVATAR_DEVICE_PATH,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("resolveMeetAvatarDevicePath honors the VELLUM_MEET_AVATAR_DEVICE override", () => {
|
|
124
|
+
expect(
|
|
125
|
+
resolveMeetAvatarDevicePath({
|
|
126
|
+
[MEET_AVATAR_ENV_VAR]: "1",
|
|
127
|
+
[MEET_AVATAR_DEVICE_ENV_VAR]: "/dev/video11",
|
|
128
|
+
}),
|
|
129
|
+
).toBe("/dev/video11");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("assistant args omit --device and the avatar env vars when VELLUM_MEET_AVATAR is unset", () => {
|
|
133
|
+
const args = buildAssistantArgs();
|
|
134
|
+
expect(args).not.toContain("--device");
|
|
135
|
+
expect(
|
|
136
|
+
args.some((a) => a.startsWith(`${MEET_AVATAR_ENV_VAR}=`)),
|
|
137
|
+
).toBe(false);
|
|
138
|
+
expect(
|
|
139
|
+
args.some((a) => a.startsWith(`${MEET_AVATAR_DEVICE_ENV_VAR}=`)),
|
|
140
|
+
).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("assistant args include --device=/dev/video10:/dev/video10 when VELLUM_MEET_AVATAR=1", () => {
|
|
144
|
+
process.env[MEET_AVATAR_ENV_VAR] = "1";
|
|
145
|
+
const args = buildAssistantArgs();
|
|
146
|
+
const deviceIdx = args.indexOf("--device");
|
|
147
|
+
expect(deviceIdx).toBeGreaterThan(0);
|
|
148
|
+
expect(args[deviceIdx + 1]).toBe(
|
|
149
|
+
`${DEFAULT_MEET_AVATAR_DEVICE_PATH}:${DEFAULT_MEET_AVATAR_DEVICE_PATH}`,
|
|
150
|
+
);
|
|
151
|
+
// The env var must also be propagated into the container so the daemon
|
|
152
|
+
// knows to turn on avatar passthrough when spawning the bot.
|
|
153
|
+
expect(args).toContain(`${MEET_AVATAR_ENV_VAR}=1`);
|
|
154
|
+
expect(args).toContain(
|
|
155
|
+
`${MEET_AVATAR_DEVICE_ENV_VAR}=${DEFAULT_MEET_AVATAR_DEVICE_PATH}`,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("assistant args honor a custom device path from VELLUM_MEET_AVATAR_DEVICE", () => {
|
|
160
|
+
process.env[MEET_AVATAR_ENV_VAR] = "1";
|
|
161
|
+
process.env[MEET_AVATAR_DEVICE_ENV_VAR] = "/dev/video11";
|
|
162
|
+
const args = buildAssistantArgs();
|
|
163
|
+
const deviceIdx = args.indexOf("--device");
|
|
164
|
+
expect(deviceIdx).toBeGreaterThan(0);
|
|
165
|
+
expect(args[deviceIdx + 1]).toBe("/dev/video11:/dev/video11");
|
|
166
|
+
expect(args).toContain(`${MEET_AVATAR_DEVICE_ENV_VAR}=/dev/video11`);
|
|
167
|
+
});
|
|
168
|
+
});
|