@vellumai/cli 0.6.6 → 0.7.1

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.
Files changed (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -13,9 +13,7 @@
13
13
  */
14
14
 
15
15
  import {
16
- findAssistantByName,
17
- getActiveAssistant,
18
- loadLatestAssistant,
16
+ resolveAssistant,
19
17
  } from "./assistant-config.js";
20
18
  import { GATEWAY_PORT } from "./constants.js";
21
19
  import { loadGuardianToken } from "./guardian-token.js";
@@ -58,27 +56,13 @@ export class AssistantClient {
58
56
  * @throws If no matching assistant is found.
59
57
  */
60
58
  constructor(opts?: AssistantClientOpts) {
61
- const nameOrId = opts?.assistantId;
62
- let entry = nameOrId ? findAssistantByName(nameOrId) : null;
63
-
64
- if (nameOrId && !entry) {
65
- throw new Error(`No assistant found with name '${nameOrId}'.`);
66
- }
67
-
68
- if (!entry) {
69
- const active = getActiveAssistant();
70
- if (active) {
71
- entry = findAssistantByName(active);
72
- }
73
- }
74
-
75
- if (!entry) {
76
- entry = loadLatestAssistant();
77
- }
59
+ const entry = resolveAssistant(opts?.assistantId);
78
60
 
79
61
  if (!entry) {
80
62
  throw new Error(
81
- "No assistant found. Hatch one first with 'vellum hatch'.",
63
+ opts?.assistantId
64
+ ? `No assistant found with name '${opts.assistantId}'.`
65
+ : "No assistant found. Hatch one first with 'vellum hatch'.",
82
66
  );
83
67
  }
84
68
 
@@ -42,8 +42,6 @@ export interface LocalInstanceResources {
42
42
  qdrantPort: number;
43
43
  /** HTTP port for the CES (Claude Extension Server) */
44
44
  cesPort: number;
45
- /** Absolute path to the daemon PID file */
46
- pidFile: string;
47
45
  /** Persisted HMAC signing key (hex). Survives daemon/gateway restarts so
48
46
  * client actor tokens remain valid across `wake` cycles. */
49
47
  signingKey?: string;
@@ -114,6 +112,18 @@ export function getBaseDir(): string {
114
112
  return process.env.BASE_DATA_DIR?.trim() || homedir();
115
113
  }
116
114
 
115
+ /**
116
+ * Derive the daemon PID file path from a resources object. The PID file
117
+ * lives inside the instance's workspace directory. When no resources are
118
+ * available, falls back to `~/.vellum/workspace/vellum.pid`.
119
+ */
120
+ export function getDaemonPidPath(resources?: LocalInstanceResources): string {
121
+ const vellumDir = resources
122
+ ? join(resources.instanceDir, ".vellum")
123
+ : join(homedir(), ".vellum");
124
+ return join(vellumDir, "workspace", "vellum.pid");
125
+ }
126
+
117
127
  function readLockfile(): LockfileData {
118
128
  for (const lockfilePath of getLockfilePaths(getCurrentEnvironment())) {
119
129
  if (!existsSync(lockfilePath)) continue;
@@ -215,7 +225,6 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
215
225
  gatewayPort,
216
226
  qdrantPort: defaultPorts.qdrant,
217
227
  cesPort: defaultPorts.ces,
218
- pidFile: join(instanceDir, ".vellum", "vellum.pid"),
219
228
  };
220
229
  mutated = true;
221
230
  } else {
@@ -247,10 +256,6 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
247
256
  res.cesPort = defaultPorts.ces;
248
257
  mutated = true;
249
258
  }
250
- if (typeof res.pidFile !== "string") {
251
- res.pidFile = join(res.instanceDir as string, ".vellum", "vellum.pid");
252
- mutated = true;
253
- }
254
259
  }
255
260
 
256
261
  return mutated;
@@ -338,19 +343,17 @@ export function setActiveAssistant(assistantId: string): void {
338
343
  }
339
344
 
340
345
  /**
341
- * Resolve which assistant to target for a command. Priority:
346
+ * Best-effort resolution of the target assistant. Returns null when no
347
+ * match is found — callers decide how to handle the absence.
348
+ *
349
+ * Priority:
342
350
  * 1. Explicit name argument
343
351
  * 2. Active assistant set via `vellum use`
344
- * 3. Sole local assistant (when exactly one exists)
352
+ * 3. Sole lockfile entry (any cloud)
345
353
  */
346
- export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
354
+ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
347
355
  if (nameArg) {
348
- const entry = findAssistantByName(nameArg);
349
- if (!entry) {
350
- console.error(`No assistant found with name '${nameArg}'.`);
351
- process.exit(1);
352
- }
353
- return entry;
356
+ return findAssistantByName(nameArg);
354
357
  }
355
358
 
356
359
  const active = getActiveAssistant();
@@ -361,15 +364,35 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
361
364
  }
362
365
 
363
366
  const all = readAssistants();
364
- const locals = all.filter((e) => e.cloud === "local");
365
- if (locals.length === 1) return locals[0];
367
+ if (all.length === 1) return all[0];
366
368
 
367
- if (locals.length === 0) {
368
- console.error("No local assistant found. Run 'vellum hatch local' first.");
369
+ return null;
370
+ }
371
+
372
+ /**
373
+ * Resolve which assistant to target for a command, exiting the process
374
+ * with a user-facing error when resolution fails.
375
+ *
376
+ * Priority:
377
+ * 1. Explicit name argument
378
+ * 2. Active assistant set via `vellum use`
379
+ * 3. Sole lockfile entry (any cloud)
380
+ */
381
+ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
382
+ const entry = resolveAssistant(nameArg);
383
+ if (entry) return entry;
384
+
385
+ if (nameArg) {
386
+ console.error(`No assistant found with name '${nameArg}'.`);
369
387
  } else {
370
- console.error(
371
- `Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,
372
- );
388
+ const all = readAssistants();
389
+ if (all.length === 0) {
390
+ console.error("No assistant found. Run 'vellum hatch' first.");
391
+ } else {
392
+ console.error(
393
+ `Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,
394
+ );
395
+ }
373
396
  }
374
397
  process.exit(1);
375
398
  }
@@ -474,7 +497,6 @@ export async function allocateLocalResources(
474
497
  gatewayPort,
475
498
  qdrantPort,
476
499
  cesPort,
477
- pidFile: join(instanceDir, ".vellum", "vellum.pid"),
478
500
  };
479
501
  }
480
502
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  /** Known error categories emitted by CLI commands. */
11
11
  export type CliErrorCategory =
12
+ | "CLI_UPDATE_FAILED"
12
13
  | "DOCKER_NOT_RUNNING"
13
14
  | "IMAGE_PULL_FAILED"
14
15
  | "MISSING_VERSION"
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Stable per-install client identity for the CLI.
3
+ *
4
+ * Generates a UUID on first use and persists it to
5
+ * `~/.config/vellum/client-id` so the daemon's event hub can
6
+ * track this terminal across SSE reconnects and CLI restarts.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
+ import { randomUUID } from "crypto";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+
14
+ const CLI_INTERFACE_ID = "cli";
15
+
16
+ let cached: string | null = null;
17
+
18
+ function getConfigDir(): string {
19
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
20
+ return join(configHome, "vellum");
21
+ }
22
+
23
+ /**
24
+ * Returns a stable UUID identifying this CLI installation.
25
+ * Generated once and persisted to `~/.config/vellum/client-id`.
26
+ */
27
+ export function getClientId(): string {
28
+ if (cached) return cached;
29
+
30
+ const configDir = getConfigDir();
31
+ const idFile = join(configDir, "client-id");
32
+
33
+ try {
34
+ if (existsSync(idFile)) {
35
+ const stored = readFileSync(idFile, "utf-8").trim();
36
+ if (stored) {
37
+ cached = stored;
38
+ return stored;
39
+ }
40
+ }
41
+ } catch {
42
+ /* best-effort read */
43
+ }
44
+
45
+ const id = randomUUID();
46
+ try {
47
+ mkdirSync(configDir, { recursive: true });
48
+ writeFileSync(idFile, id, "utf-8");
49
+ } catch {
50
+ /* best-effort persist — transient id still works for this session */
51
+ }
52
+
53
+ cached = id;
54
+ return id;
55
+ }
56
+
57
+ /**
58
+ * Headers that identify this CLI client to the assistant daemon.
59
+ * Attach to SSE streaming connections so the ClientRegistry can
60
+ * track connected clients and their capabilities.
61
+ */
62
+ export function getClientRegistrationHeaders(): Record<string, string> {
63
+ return {
64
+ "X-Vellum-Client-Id": getClientId(),
65
+ "X-Vellum-Interface-Id": CLI_INTERFACE_ID,
66
+ };
67
+ }
package/src/lib/docker.ts CHANGED
@@ -6,13 +6,6 @@ import { dirname, join } from "path";
6
6
  // Direct import — bun embeds this at compile time so it works in compiled binaries.
7
7
  import cliPkg from "../../package.json";
8
8
 
9
- // Pulled from skills/ — the Meet avatar device-path default is owned by the
10
- // meet-join skill; importing here keeps the CLI's Docker wiring locked to the
11
- // same value the bot and config schema use. The shared module is deliberately
12
- // zero-dep so this import cannot drag unrelated surface into the compiled CLI
13
- // binary.
14
- import { AVATAR_DEVICE_PATH_DEFAULT } from "../../../skills/meet-join/shared/avatar-device-path.js";
15
-
16
9
  import {
17
10
  findAssistantByName,
18
11
  saveAssistantEntry,
@@ -53,62 +46,24 @@ export const GATEWAY_INTERNAL_PORT = 7830;
53
46
  /** Max time to wait for the assistant container to emit the readiness sentinel. */
54
47
  export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
55
48
 
56
- /**
57
- * Default virtual-camera device path when the Meet avatar feature is
58
- * enabled. Re-exports the shared
59
- * {@link ../../../skills/meet-join/shared/avatar-device-path.js AVATAR_DEVICE_PATH_DEFAULT}
60
- * so the CLI's device-passthrough wiring cannot drift from the bot's
61
- * Chrome-flag wiring or the workspace config default. Matches the
62
- * `video_nr=10` value in the README's host-setup section
63
- * (`skills/meet-join/bot/README.md`). Operators can override the path by
64
- * setting `VELLUM_MEET_AVATAR_DEVICE` to something other than the default
65
- * (e.g. `/dev/video11` if a different `video_nr` was used).
66
- */
67
- export const DEFAULT_MEET_AVATAR_DEVICE_PATH = AVATAR_DEVICE_PATH_DEFAULT;
68
-
69
- /**
70
- * Env-var opt-in for bind-mounting the v4l2loopback virtual-camera device
71
- * into the assistant container. Set to a truthy value (`1`, `true`, `yes`)
72
- * to enable; unset or falsy disables the passthrough entirely.
73
- *
74
- * Kept as an env-var rather than a config-schema field because the Meet
75
- * avatar config schema lands in a later PR (PR 5 of the phase-4 plan) —
76
- * threading the config through the CLI's boot flow now would force a
77
- * forward dependency. Once the schema lands, the CLI can either keep this
78
- * env-var as a pre-config override or move the opt-in into the workspace
79
- * config.
80
- */
81
- export const MEET_AVATAR_ENV_VAR = "VELLUM_MEET_AVATAR";
49
+ /** Default virtual-camera device path. Overridable via `VELLUM_AVATAR_DEVICE`. */
50
+ const DEFAULT_AVATAR_DEVICE_PATH = "/dev/video10";
82
51
 
83
- /**
84
- * Override for the virtual-camera device path. Defaults to
85
- * {@link DEFAULT_MEET_AVATAR_DEVICE_PATH}.
86
- */
87
- export const MEET_AVATAR_DEVICE_ENV_VAR = "VELLUM_MEET_AVATAR_DEVICE";
52
+ /** Env var the assistant reads to discover its virtual-camera device path. */
53
+ export const AVATAR_DEVICE_ENV_VAR = "VELLUM_AVATAR_DEVICE";
88
54
 
89
55
  /**
90
- * Resolve the Meet avatar device path to pass through to the assistant
91
- * container, or `null` if the feature is not opted into. Exported so tests
92
- * can assert against the env-var parsing without reaching into the shell.
56
+ * Resolve the avatar device path from the environment. Always returns a
57
+ * value the CLI unconditionally passes the device path to the assistant
58
+ * container; the skill decides whether to use it.
93
59
  */
94
- export function resolveMeetAvatarDevicePath(
60
+ export function resolveAvatarDevicePath(
95
61
  env: NodeJS.ProcessEnv = process.env,
96
- ): string | null {
97
- const flag = env[MEET_AVATAR_ENV_VAR];
98
- if (!flag) return null;
99
- const normalized = flag.trim().toLowerCase();
100
- if (
101
- normalized === "" ||
102
- normalized === "0" ||
103
- normalized === "false" ||
104
- normalized === "no"
105
- ) {
106
- return null;
107
- }
108
- const override = env[MEET_AVATAR_DEVICE_ENV_VAR];
62
+ ): string {
63
+ const override = env[AVATAR_DEVICE_ENV_VAR];
109
64
  return override && override.length > 0
110
65
  ? override
111
- : DEFAULT_MEET_AVATAR_DEVICE_PATH;
66
+ : DEFAULT_AVATAR_DEVICE_PATH;
112
67
  }
113
68
 
114
69
  /** Default memory (GiB) allocated to the Colima VM. */
@@ -405,10 +360,12 @@ async function ensureDockerInstalled(): Promise<void> {
405
360
  export function dockerResourceNames(instanceName: string) {
406
361
  return {
407
362
  assistantContainer: `${instanceName}-assistant`,
363
+ assistantIpcVolume: `${instanceName}-assistant-ipc`,
408
364
  cesContainer: `${instanceName}-credential-executor`,
409
365
  cesSecurityVolume: `${instanceName}-ces-sec`,
410
366
  dockerdDataVolume: `${instanceName}-dockerd-data`,
411
367
  gatewayContainer: `${instanceName}-gateway`,
368
+ gatewayIpcVolume: `${instanceName}-gateway-ipc`,
412
369
  gatewaySecurityVolume: `${instanceName}-gateway-sec`,
413
370
  network: `${instanceName}-net`,
414
371
  socketVolume: `${instanceName}-socket`,
@@ -464,6 +421,8 @@ export async function retireDocker(name: string): Promise<void> {
464
421
  }
465
422
  for (const vol of [
466
423
  res.socketVolume,
424
+ res.assistantIpcVolume,
425
+ res.gatewayIpcVolume,
467
426
  res.workspaceVolume,
468
427
  res.cesSecurityVolume,
469
428
  res.gatewaySecurityVolume,
@@ -635,8 +594,19 @@ export function serviceDockerRunArgs(opts: {
635
594
  // container runs its own `dockerd` so the Meet subsystem can spawn
636
595
  // sibling meet-bot containers without needing access to the host's
637
596
  // Docker engine. This requires:
638
- // - `--privileged` so the inner dockerd can manage cgroups, iptables,
639
- // overlayfs mounts, etc.
597
+ // - `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` so the inner dockerd can
598
+ // configure cgroups, overlay mounts, network namespaces, and
599
+ // iptables. We deliberately avoid `--privileged` (which grants the
600
+ // full host capability set and access to every host device node)
601
+ // to shrink the escape surface from any code running inside the
602
+ // assistant container. See the "Security tradeoff for Docker mode"
603
+ // note in AGENTS.md.
604
+ // - `seccomp=unconfined` + `apparmor=unconfined` because Docker's
605
+ // default seccomp profile blocks syscalls dockerd needs (e.g.
606
+ // certain clone/unshare and pivot_root flags) and the default
607
+ // AppArmor profile on Debian/Ubuntu hosts denies the mount
608
+ // operations dockerd performs while launching bot containers. On
609
+ // hosts where these LSMs are inactive, the options are no-ops.
640
610
  // - A dedicated named volume mounted at `/var/lib/docker` so the
641
611
  // inner Docker image cache and container state survive restarts of
642
612
  // the assistant container.
@@ -646,7 +616,14 @@ export function serviceDockerRunArgs(opts: {
646
616
  "run",
647
617
  "--init",
648
618
  "-d",
649
- "--privileged",
619
+ "--cap-add",
620
+ "SYS_ADMIN",
621
+ "--cap-add",
622
+ "NET_ADMIN",
623
+ "--security-opt",
624
+ "seccomp=unconfined",
625
+ "--security-opt",
626
+ "apparmor=unconfined",
650
627
  "--name",
651
628
  res.assistantContainer,
652
629
  `--network=${res.network}`,
@@ -677,10 +654,16 @@ export function serviceDockerRunArgs(opts: {
677
654
  "-v",
678
655
  `${res.socketVolume}:/run/ces-bootstrap`,
679
656
  "-v",
657
+ `${res.assistantIpcVolume}:/run/assistant-ipc`,
658
+ "-v",
659
+ `${res.gatewayIpcVolume}:/run/gateway-ipc`,
660
+ "-v",
680
661
  `${res.dockerdDataVolume}:/var/lib/docker`,
681
662
  "-e",
682
663
  "IS_CONTAINERIZED=true",
683
664
  "-e",
665
+ "DEBUG_STDOUT_LOGS=1",
666
+ "-e",
684
667
  `VELLUM_ASSISTANT_NAME=${instanceName}`,
685
668
  "-e",
686
669
  "VELLUM_CLOUD=docker",
@@ -696,6 +679,10 @@ export function serviceDockerRunArgs(opts: {
696
679
  "CES_CREDENTIAL_URL=http://localhost:8090",
697
680
  "-e",
698
681
  `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
682
+ "-e",
683
+ "GATEWAY_IPC_SOCKET_DIR=/run/gateway-ipc",
684
+ "-e",
685
+ "ASSISTANT_IPC_SOCKET_DIR=/run/assistant-ipc",
699
686
  ];
700
687
  if (defaultWorkspaceConfigPath) {
701
688
  const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
@@ -712,6 +699,14 @@ export function serviceDockerRunArgs(opts: {
712
699
  if (opts.signingKey) {
713
700
  args.push("-e", `ACTOR_TOKEN_SIGNING_KEY=${opts.signingKey}`);
714
701
  }
702
+ if (opts.bootstrapSecret) {
703
+ // Mirror the secret into the assistant container so the runtime's
704
+ // guardian-bootstrap handler can validate the x-bootstrap-secret
705
+ // header forwarded by the gateway. Without this, the published
706
+ // runtime port would expose an unauthenticated token-minting
707
+ // endpoint reachable from the host bypassing the gateway's gate.
708
+ args.push("-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`);
709
+ }
715
710
  for (const envVar of [
716
711
  ...Object.values(PROVIDER_ENV_VAR_NAMES),
717
712
  "VELLUM_ENVIRONMENT",
@@ -726,22 +721,13 @@ export function serviceDockerRunArgs(opts: {
726
721
  args.push("-e", `${key}=${value}`);
727
722
  }
728
723
  }
729
- // Optional Meet avatar (v4l2loopback) passthrough. When
730
- // `VELLUM_MEET_AVATAR=1` is set in the caller's environment, bind the
731
- // virtual-camera device node (default `/dev/video10`) into the
732
- // assistant container so the nested `dockerd` can in turn pass it
733
- // through to the Meet-bot container. The daemon-side equivalent of
734
- // this opt-in lives on `DockerRunner.run()`'s `avatarDevicePath`
735
- // option (see `skills/meet-join/daemon/docker-runner.ts`).
736
- const avatarDevice = resolveMeetAvatarDevicePath();
737
- if (avatarDevice) {
724
+ const avatarDevice = resolveAvatarDevicePath();
725
+ if (existsSync(avatarDevice)) {
738
726
  args.push(
739
727
  "--device",
740
728
  `${avatarDevice}:${avatarDevice}`,
741
729
  "-e",
742
- `${MEET_AVATAR_ENV_VAR}=1`,
743
- "-e",
744
- `${MEET_AVATAR_DEVICE_ENV_VAR}=${avatarDevice}`,
730
+ `${AVATAR_DEVICE_ENV_VAR}=${avatarDevice}`,
745
731
  );
746
732
  }
747
733
  args.push(imageTags.assistant);
@@ -758,6 +744,10 @@ export function serviceDockerRunArgs(opts: {
758
744
  `${res.workspaceVolume}:/workspace`,
759
745
  "-v",
760
746
  `${res.gatewaySecurityVolume}:/gateway-security`,
747
+ "-v",
748
+ `${res.assistantIpcVolume}:/run/assistant-ipc`,
749
+ "-v",
750
+ `${res.gatewayIpcVolume}:/run/gateway-ipc`,
761
751
  "-e",
762
752
  "VELLUM_WORKSPACE_DIR=/workspace",
763
753
  "-e",
@@ -769,9 +759,11 @@ export function serviceDockerRunArgs(opts: {
769
759
  "-e",
770
760
  `RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
771
761
  "-e",
772
- "RUNTIME_PROXY_ENABLED=true",
773
- "-e",
774
762
  "CES_CREDENTIAL_URL=http://localhost:8090",
763
+ "-e",
764
+ "GATEWAY_IPC_SOCKET_DIR=/run/gateway-ipc",
765
+ "-e",
766
+ "ASSISTANT_IPC_SOCKET_DIR=/run/assistant-ipc",
775
767
  ...(cesServiceToken
776
768
  ? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
777
769
  : []),
@@ -1257,21 +1249,27 @@ export async function hatchDocker(
1257
1249
  log("📁 Creating network and volumes...");
1258
1250
  await exec("docker", ["network", "create", res.network]);
1259
1251
  await exec("docker", ["volume", "create", res.socketVolume]);
1252
+ await exec("docker", ["volume", "create", res.assistantIpcVolume]);
1253
+ await exec("docker", ["volume", "create", res.gatewayIpcVolume]);
1260
1254
  await exec("docker", ["volume", "create", res.workspaceVolume]);
1261
1255
  await exec("docker", ["volume", "create", res.cesSecurityVolume]);
1262
1256
  await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
1263
1257
  await exec("docker", ["volume", "create", res.dockerdDataVolume]);
1264
1258
 
1265
- // Set workspace volume ownership so non-root containers (UID 1001) can write.
1259
+ // Set volume ownership so non-root containers (UID 1001) can write.
1266
1260
  await exec("docker", [
1267
1261
  "run",
1268
1262
  "--rm",
1269
1263
  "-v",
1270
1264
  `${res.workspaceVolume}:/workspace`,
1265
+ "-v",
1266
+ `${res.assistantIpcVolume}:/run/assistant-ipc`,
1267
+ "-v",
1268
+ `${res.gatewayIpcVolume}:/run/gateway-ipc`,
1271
1269
  "busybox",
1272
- "chown",
1273
- "1001:1001",
1274
- "/workspace",
1270
+ "sh",
1271
+ "-c",
1272
+ "chown 1001:1001 /workspace /run/assistant-ipc /run/gateway-ipc",
1275
1273
  ]);
1276
1274
 
1277
1275
  // Write --config key=value pairs to a temp file that gets bind-mounted
@@ -32,11 +32,13 @@ type EnvironmentDefinition = import("../types.js").EnvironmentDefinition;
32
32
  const prod: EnvironmentDefinition = {
33
33
  name: "production",
34
34
  platformUrl: "https://platform.vellum.ai",
35
+ webUrl: "https://www.vellum.ai",
35
36
  };
36
37
 
37
38
  const dev: EnvironmentDefinition = {
38
39
  name: "dev",
39
40
  platformUrl: "https://dev-platform.vellum.ai",
41
+ webUrl: "https://dev-assistant.vellum.ai",
40
42
  };
41
43
 
42
44
  const XDG_ENV_VARS = ["XDG_DATA_HOME", "XDG_CONFIG_HOME"] as const;
@@ -1,8 +1,65 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ unlinkSync,
6
+ writeFileSync,
7
+ } from "fs";
8
+ import { homedir } from "os";
9
+ import { dirname, join } from "path";
10
+
1
11
  import { SEEDS } from "./seeds.js";
2
12
  import type { EnvironmentDefinition } from "./types.js";
3
13
 
4
14
  const DEFAULT_ENVIRONMENT_NAME = "production";
5
15
 
16
+ /**
17
+ * Path to the user's persisted default environment file.
18
+ * Lives at `~/.config/vellum/environment` — a fixed, environment-agnostic
19
+ * location so it can be read before the environment is resolved.
20
+ */
21
+ function getDefaultEnvironmentPath(): string {
22
+ const xdgConfig =
23
+ process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
24
+ return join(xdgConfig, "vellum", "environment");
25
+ }
26
+
27
+ /**
28
+ * Read the persisted default environment name, if any.
29
+ * Returns `undefined` if no file exists or the file is empty.
30
+ */
31
+ export function readDefaultEnvironment(): string | undefined {
32
+ const filePath = getDefaultEnvironmentPath();
33
+ try {
34
+ if (!existsSync(filePath)) return undefined;
35
+ const content = readFileSync(filePath, "utf-8").trim();
36
+ return content.length > 0 ? content : undefined;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Persist a default environment name to the user config file.
44
+ */
45
+ export function writeDefaultEnvironment(name: string): void {
46
+ const filePath = getDefaultEnvironmentPath();
47
+ mkdirSync(dirname(filePath), { recursive: true });
48
+ writeFileSync(filePath, name + "\n", "utf-8");
49
+ }
50
+
51
+ /**
52
+ * Remove the persisted default environment file, falling back to production.
53
+ */
54
+ export function clearDefaultEnvironment(): void {
55
+ const filePath = getDefaultEnvironmentPath();
56
+ try {
57
+ unlinkSync(filePath);
58
+ } catch {
59
+ // Already absent — nothing to do.
60
+ }
61
+ }
62
+
6
63
  /**
7
64
  * Look up a seed entry by name. Returns `undefined` if no seed matches.
8
65
  * Callers that need the full resolution stack (env-var overrides, default
@@ -22,12 +79,13 @@ export function getSeed(name: string): EnvironmentDefinition | undefined {
22
79
  * Priority:
23
80
  * 1. `override` argument (from a `--environment` CLI flag, when wired)
24
81
  * 2. `VELLUM_ENVIRONMENT` env var
25
- * 3. (future) user context file
82
+ * 3. User config file (`~/.config/vellum/environment`, set via `vellum env set`)
26
83
  * 4. Default: `production`
27
84
  *
28
85
  * Per-field env-var overrides are honored on the resolved definition as
29
86
  * ad-hoc escape hatches (they do not materialize new environments):
30
87
  * - `VELLUM_PLATFORM_URL` overrides `platformUrl`
88
+ * - `VELLUM_WEB_URL` overrides `webUrl`
31
89
  * - `VELLUM_ASSISTANT_PLATFORM_URL` overrides `assistantPlatformUrl`
32
90
  * - `VELLUM_LOCKFILE_DIR` overrides `lockfileDirOverride` (legacy e2e
33
91
  * test hook)
@@ -38,7 +96,15 @@ export function getSeed(name: string): EnvironmentDefinition | undefined {
38
96
  export function getCurrentEnvironment(
39
97
  override?: string,
40
98
  ): EnvironmentDefinition {
41
- const name = resolveEnvironmentName(override);
99
+ const { name, source } = resolveEnvironmentSource(override);
100
+
101
+ // When the environment was resolved from the config file, propagate it
102
+ // into process.env so child processes (daemon, gateway) inherit the same
103
+ // environment without needing to read the config file themselves.
104
+ if (source === "config" && !process.env.VELLUM_ENVIRONMENT) {
105
+ process.env.VELLUM_ENVIRONMENT = name;
106
+ }
107
+
42
108
  const seed = SEEDS[name];
43
109
  if (!seed) {
44
110
  if (name !== DEFAULT_ENVIRONMENT_NAME) {
@@ -68,6 +134,11 @@ export function getCurrentEnvironment(
68
134
  resolved.platformUrl = platformUrlOverride;
69
135
  }
70
136
 
137
+ const webUrlOverride = process.env.VELLUM_WEB_URL?.trim();
138
+ if (webUrlOverride) {
139
+ resolved.webUrl = webUrlOverride;
140
+ }
141
+
71
142
  const assistantPlatformUrlOverride =
72
143
  process.env.VELLUM_ASSISTANT_PLATFORM_URL?.trim();
73
144
  if (assistantPlatformUrlOverride) {
@@ -82,15 +153,26 @@ export function getCurrentEnvironment(
82
153
  return resolved;
83
154
  }
84
155
 
85
- function resolveEnvironmentName(override: string | undefined): string {
156
+ /**
157
+ * Resolve the environment name and its source for diagnostics.
158
+ */
159
+ export function resolveEnvironmentSource(override?: string): {
160
+ name: string;
161
+ source: "flag" | "env" | "config" | "default";
162
+ } {
86
163
  const trimmedOverride = override?.trim();
87
164
  if (trimmedOverride && trimmedOverride.length > 0) {
88
- return trimmedOverride;
165
+ return { name: trimmedOverride, source: "flag" };
89
166
  }
90
167
  const envVar = process.env.VELLUM_ENVIRONMENT?.trim();
91
168
  if (envVar && envVar.length > 0) {
92
- return envVar;
169
+ return { name: envVar, source: "env" };
93
170
  }
94
-
95
- return DEFAULT_ENVIRONMENT_NAME;
171
+ const configDefault = readDefaultEnvironment();
172
+ if (configDefault) {
173
+ return { name: configDefault, source: "config" };
174
+ }
175
+ return { name: DEFAULT_ENVIRONMENT_NAME, source: "default" };
96
176
  }
177
+
178
+