@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.
@@ -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
+ }
@@ -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
+ });