@vellumai/cli 0.6.3 → 0.6.5

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 (56) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bun.lock +17 -17
  4. package/bunfig.toml +6 -0
  5. package/package.json +18 -18
  6. package/src/__tests__/assistant-config.test.ts +124 -0
  7. package/src/__tests__/env-drift.test.ts +87 -0
  8. package/src/__tests__/guardian-token.test.ts +225 -0
  9. package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
  10. package/src/__tests__/multi-local.test.ts +90 -13
  11. package/src/__tests__/orphan-detection.test.ts +214 -0
  12. package/src/__tests__/platform-client.test.ts +204 -0
  13. package/src/__tests__/preload.ts +27 -0
  14. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  15. package/src/__tests__/teleport.test.ts +1073 -56
  16. package/src/commands/backup.ts +8 -0
  17. package/src/commands/exec.ts +186 -0
  18. package/src/commands/hatch.ts +1 -1
  19. package/src/commands/login.ts +209 -9
  20. package/src/commands/logs.ts +652 -0
  21. package/src/commands/pair.ts +9 -1
  22. package/src/commands/ps.ts +37 -7
  23. package/src/commands/recover.ts +8 -4
  24. package/src/commands/restore.ts +8 -0
  25. package/src/commands/retire.ts +16 -9
  26. package/src/commands/rollback.ts +32 -33
  27. package/src/commands/ssh.ts +7 -0
  28. package/src/commands/teleport.ts +253 -1
  29. package/src/commands/upgrade.ts +43 -52
  30. package/src/commands/wake.ts +25 -10
  31. package/src/components/DefaultMainScreen.tsx +7 -1
  32. package/src/index.ts +6 -0
  33. package/src/lib/__tests__/docker.test.ts +168 -0
  34. package/src/lib/assistant-config.ts +82 -108
  35. package/src/lib/aws.ts +12 -1
  36. package/src/lib/config-utils.ts +4 -4
  37. package/src/lib/constants.ts +0 -10
  38. package/src/lib/docker.ts +158 -8
  39. package/src/lib/environments/__tests__/paths.test.ts +228 -0
  40. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  41. package/src/lib/environments/__tests__/seeds.test.ts +72 -0
  42. package/src/lib/environments/paths.ts +109 -0
  43. package/src/lib/environments/resolve.ts +96 -0
  44. package/src/lib/environments/seeds.ts +74 -0
  45. package/src/lib/environments/types.ts +60 -0
  46. package/src/lib/exec-apple-container.ts +122 -0
  47. package/src/lib/gcp.ts +12 -1
  48. package/src/lib/guardian-token.ts +71 -10
  49. package/src/lib/hatch-local.ts +44 -23
  50. package/src/lib/local.ts +47 -5
  51. package/src/lib/orphan-detection.ts +28 -12
  52. package/src/lib/platform-client.ts +354 -24
  53. package/src/lib/retire-apple-container.ts +102 -0
  54. package/src/lib/ssh-apple-container.ts +166 -0
  55. package/src/lib/upgrade-lifecycle.ts +101 -28
  56. package/src/shared/provider-env-vars.ts +30 -6
package/src/index.ts CHANGED
@@ -5,8 +5,10 @@ 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";
11
+ import { logs } from "./commands/logs";
10
12
  import { message } from "./commands/message";
11
13
  import { pair } from "./commands/pair";
12
14
  import { ps } from "./commands/ps";
@@ -36,9 +38,11 @@ const commands = {
36
38
  clean,
37
39
  client,
38
40
  events,
41
+ exec,
39
42
  hatch,
40
43
  login,
41
44
  logout,
45
+ logs,
42
46
  message,
43
47
  pair,
44
48
  ps,
@@ -67,7 +71,9 @@ function printHelp(): void {
67
71
  console.log(" clean Kill orphaned vellum processes");
68
72
  console.log(" client Connect to a hatched assistant");
69
73
  console.log(" events Stream events from a running assistant");
74
+ console.log(" exec Execute a command inside an assistant's container");
70
75
  console.log(" hatch Create a new assistant instance");
76
+ console.log(" logs View logs from an assistant instance");
71
77
  console.log(" login Log in to the Vellum platform");
72
78
  console.log(" logout Log out of the Vellum platform");
73
79
  console.log(" message Send a message to a running assistant");
@@ -0,0 +1,168 @@
1
+ import { afterEach, beforeEach, describe, test, expect } from "bun:test";
2
+ import {
3
+ ASSISTANT_INTERNAL_PORT,
4
+ DEFAULT_MEET_AVATAR_DEVICE_PATH,
5
+ dockerResourceNames,
6
+ MEET_AVATAR_DEVICE_ENV_VAR,
7
+ MEET_AVATAR_ENV_VAR,
8
+ resolveMeetAvatarDevicePath,
9
+ serviceDockerRunArgs,
10
+ type ServiceName,
11
+ } from "../docker.js";
12
+
13
+ const instanceName = "test-instance";
14
+ const imageTags: Record<ServiceName, string> = {
15
+ assistant: "vellumai/vellum-assistant:test",
16
+ "credential-executor": "vellumai/vellum-credential-executor:test",
17
+ gateway: "vellumai/vellum-gateway:test",
18
+ };
19
+
20
+ function buildAssistantArgs(): string[] {
21
+ const res = dockerResourceNames(instanceName);
22
+ const builders = serviceDockerRunArgs({
23
+ gatewayPort: 7830,
24
+ imageTags,
25
+ instanceName,
26
+ res,
27
+ });
28
+ return builders.assistant();
29
+ }
30
+
31
+ describe("serviceDockerRunArgs — assistant", () => {
32
+ test("runs privileged so the inner dockerd can manage cgroups/iptables/overlayfs", () => {
33
+ const args = buildAssistantArgs();
34
+ expect(args).toContain("--privileged");
35
+ });
36
+
37
+ test("mounts a dedicated named volume at /var/lib/docker for the inner dockerd data store", () => {
38
+ const args = buildAssistantArgs();
39
+ const spec = `${instanceName}-dockerd-data:/var/lib/docker`;
40
+ const mountIndex = args.indexOf(spec);
41
+ expect(mountIndex).toBeGreaterThan(0);
42
+ expect(args[mountIndex - 1]).toBe("-v");
43
+ });
44
+
45
+ test("does NOT bind-mount the host Docker socket (DinD replaces host-socket access)", () => {
46
+ const args = buildAssistantArgs();
47
+ expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock");
48
+ });
49
+
50
+ test("does NOT set VELLUM_WORKSPACE_VOLUME_NAME (legacy Phase 1.8 hint, no longer needed in DinD)", () => {
51
+ const args = buildAssistantArgs();
52
+ expect(
53
+ args.some((a) => a.startsWith("VELLUM_WORKSPACE_VOLUME_NAME=")),
54
+ ).toBe(false);
55
+ });
56
+
57
+ test("keeps existing workspace and socket volume mounts intact", () => {
58
+ const args = buildAssistantArgs();
59
+ expect(args).toContain(`${instanceName}-workspace:/workspace`);
60
+ expect(args).toContain(`${instanceName}-socket:/run/ces-bootstrap`);
61
+ });
62
+
63
+ test("preserves existing required env vars", () => {
64
+ const args = buildAssistantArgs();
65
+ expect(args).toContain("IS_CONTAINERIZED=true");
66
+ expect(args).toContain("VELLUM_WORKSPACE_DIR=/workspace");
67
+ expect(args).toContain(`VELLUM_ASSISTANT_NAME=${instanceName}`);
68
+ });
69
+
70
+ test("publishes the assistant HTTP port on all host interfaces so sibling bot containers can reach the daemon via host.docker.internal on both Docker Desktop and Linux", () => {
71
+ const args = buildAssistantArgs();
72
+ // The port mapping is expressed as two adjacent args: "-p" then the spec.
73
+ // Bound to all interfaces (no `127.0.0.1:` prefix) because on vanilla
74
+ // Linux Docker, host.docker.internal:host-gateway resolves to the Docker
75
+ // bridge gateway IP — packets arrive at the bridge interface, not
76
+ // loopback, so a 127.0.0.1 DNAT rule would not match.
77
+ const portSpec = `${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`;
78
+ const portIndex = args.indexOf(portSpec);
79
+ expect(portIndex).toBeGreaterThan(0);
80
+ expect(args[portIndex - 1]).toBe("-p");
81
+ });
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
+ });
@@ -8,16 +8,16 @@ import {
8
8
  writeFileSync,
9
9
  } from "fs";
10
10
  import { homedir } from "os";
11
- import { join } from "path";
11
+ import { dirname, join } from "path";
12
12
 
13
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
13
14
  import {
14
- DAEMON_INTERNAL_ASSISTANT_ID,
15
- DEFAULT_CES_PORT,
16
- DEFAULT_DAEMON_PORT,
17
- DEFAULT_GATEWAY_PORT,
18
- DEFAULT_QDRANT_PORT,
19
- LOCKFILE_NAMES,
20
- } from "./constants.js";
15
+ getDefaultPorts,
16
+ getLockfilePath,
17
+ getLockfilePaths,
18
+ getMultiInstanceDir,
19
+ } from "./environments/paths.js";
20
+ import { getCurrentEnvironment } from "./environments/resolve.js";
21
21
  import { probePort } from "./port-probe.js";
22
22
 
23
23
  /**
@@ -27,10 +27,11 @@ import { probePort } from "./port-probe.js";
27
27
  */
28
28
  export interface LocalInstanceResources {
29
29
  /**
30
- * Instance-specific data root. The first local assistant uses `~` (home
31
- * directory) with default ports. Subsequent instances are placed under
32
- * `~/.local/share/vellum/assistants/<name>/`.
33
- * The daemon's `.vellum/` directory lives inside it.
30
+ * Instance-specific data root. New local assistants are placed under
31
+ * `$XDG_DATA_HOME/vellum{-env}/assistants/<name>/`. Legacy entries
32
+ * (pre env-data-layout) may still point at `~` — the read path honors
33
+ * whatever `instanceDir` is stored. The daemon's `.vellum/` directory
34
+ * lives inside it.
34
35
  */
35
36
  instanceDir: string;
36
37
  /** HTTP port for the daemon runtime server */
@@ -84,18 +85,17 @@ export interface AssistantEntry {
84
85
  resources?: LocalInstanceResources;
85
86
  /** PID of the file watcher process for docker instances hatched with --watch. */
86
87
  watcherPid?: number;
87
- /** Last-known version of the service group, populated at hatch and updated by health checks. */
88
- serviceGroupVersion?: string;
89
88
  /** Docker image metadata for rollback. Only present for docker topology entries. */
90
89
  containerInfo?: ContainerInfo;
91
- /** The service group version that was running before the last upgrade. */
92
- previousServiceGroupVersion?: string;
93
90
  /** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
94
91
  previousContainerInfo?: ContainerInfo;
95
92
  /** Path to the .vbundle backup created for the most recent upgrade. Used by rollback to restore
96
93
  * only the backup from the specific upgrade being rolled back — never a stale backup from a
97
94
  * previous upgrade cycle. */
98
95
  preUpgradeBackupPath?: string;
96
+ /** Running version of the service group at the time of the last upgrade, as reported by
97
+ * the health endpoint. Used by saved-state rollback for logging / broadcast events. */
98
+ previousVersion?: string;
99
99
  /** Pre-upgrade DB migration version — used by rollback to know how far back to revert. */
100
100
  previousDbMigrationVersion?: number;
101
101
  /** Pre-upgrade workspace migration ID — used by rollback to know how far back to revert. */
@@ -114,15 +114,8 @@ export function getBaseDir(): string {
114
114
  return process.env.BASE_DATA_DIR?.trim() || homedir();
115
115
  }
116
116
 
117
- /** The lockfile always lives under the home directory. */
118
- function getLockfileDir(): string {
119
- return process.env.VELLUM_LOCKFILE_DIR?.trim() || homedir();
120
- }
121
-
122
117
  function readLockfile(): LockfileData {
123
- const base = getLockfileDir();
124
- const candidates = LOCKFILE_NAMES.map((name) => join(base, name));
125
- for (const lockfilePath of candidates) {
118
+ for (const lockfilePath of getLockfilePaths(getCurrentEnvironment())) {
126
119
  if (!existsSync(lockfilePath)) continue;
127
120
  try {
128
121
  const raw = readFileSync(lockfilePath, "utf-8");
@@ -138,7 +131,8 @@ function readLockfile(): LockfileData {
138
131
  }
139
132
 
140
133
  function writeLockfile(data: LockfileData): void {
141
- const lockfilePath = join(getLockfileDir(), LOCKFILE_NAMES[0]);
134
+ const lockfilePath = getLockfilePath(getCurrentEnvironment());
135
+ mkdirSync(dirname(lockfilePath), { recursive: true });
142
136
  const tmpPath = `${lockfilePath}.${randomBytes(4).toString("hex")}.tmp`;
143
137
  try {
144
138
  writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
@@ -187,6 +181,8 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
187
181
  return false;
188
182
  }
189
183
 
184
+ const env = getCurrentEnvironment();
185
+ const defaultPorts = getDefaultPorts(env);
190
186
  let mutated = false;
191
187
 
192
188
  // Migrate top-level `baseDataDir` → `resources.instanceDir`
@@ -206,23 +202,19 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
206
202
  // Synthesise missing `resources` for local entries
207
203
  if (!raw.resources || typeof raw.resources !== "object") {
208
204
  const gatewayPort =
209
- parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
205
+ parsePortFromUrl(raw.runtimeUrl) ?? defaultPorts.gateway;
210
206
  const instanceDir = join(
211
- homedir(),
212
- ".local",
213
- "share",
214
- "vellum",
215
- "assistants",
207
+ getMultiInstanceDir(env),
216
208
  typeof raw.assistantId === "string"
217
209
  ? raw.assistantId
218
210
  : DAEMON_INTERNAL_ASSISTANT_ID,
219
211
  );
220
212
  raw.resources = {
221
213
  instanceDir,
222
- daemonPort: DEFAULT_DAEMON_PORT,
214
+ daemonPort: defaultPorts.daemon,
223
215
  gatewayPort,
224
- qdrantPort: DEFAULT_QDRANT_PORT,
225
- cesPort: DEFAULT_CES_PORT,
216
+ qdrantPort: defaultPorts.qdrant,
217
+ cesPort: defaultPorts.ces,
226
218
  pidFile: join(instanceDir, ".vellum", "vellum.pid"),
227
219
  };
228
220
  mutated = true;
@@ -231,11 +223,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
231
223
  const res = raw.resources as Record<string, unknown>;
232
224
  if (!res.instanceDir) {
233
225
  res.instanceDir = join(
234
- homedir(),
235
- ".local",
236
- "share",
237
- "vellum",
238
- "assistants",
226
+ getMultiInstanceDir(env),
239
227
  typeof raw.assistantId === "string"
240
228
  ? raw.assistantId
241
229
  : DAEMON_INTERNAL_ASSISTANT_ID,
@@ -243,20 +231,20 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
243
231
  mutated = true;
244
232
  }
245
233
  if (typeof res.daemonPort !== "number") {
246
- res.daemonPort = DEFAULT_DAEMON_PORT;
234
+ res.daemonPort = defaultPorts.daemon;
247
235
  mutated = true;
248
236
  }
249
237
  if (typeof res.gatewayPort !== "number") {
250
238
  res.gatewayPort =
251
- parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
239
+ parsePortFromUrl(raw.runtimeUrl) ?? defaultPorts.gateway;
252
240
  mutated = true;
253
241
  }
254
242
  if (typeof res.qdrantPort !== "number") {
255
- res.qdrantPort = DEFAULT_QDRANT_PORT;
243
+ res.qdrantPort = defaultPorts.qdrant;
256
244
  mutated = true;
257
245
  }
258
246
  if (typeof res.cesPort !== "number") {
259
- res.cesPort = DEFAULT_CES_PORT;
247
+ res.cesPort = defaultPorts.ces;
260
248
  mutated = true;
261
249
  }
262
250
  if (typeof res.pidFile !== "string") {
@@ -386,6 +374,22 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
386
374
  process.exit(1);
387
375
  }
388
376
 
377
+ /**
378
+ * Determine which cloud topology an assistant entry is running on.
379
+ */
380
+ export function resolveCloud(entry: AssistantEntry): string {
381
+ if (entry.cloud) {
382
+ return entry.cloud;
383
+ }
384
+ if (entry.project) {
385
+ return "gcp";
386
+ }
387
+ if (entry.sshUser) {
388
+ return "custom";
389
+ }
390
+ return "local";
391
+ }
392
+
389
393
  export function saveAssistantEntry(entry: AssistantEntry): void {
390
394
  const entries = readAssistants().filter(
391
395
  (e) => e.assistantId !== entry.assistantId,
@@ -394,23 +398,6 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
394
398
  writeAssistants(entries);
395
399
  }
396
400
 
397
- /**
398
- * Update just the serviceGroupVersion field on a lockfile entry.
399
- * Reads the current entry, updates the version if changed, and writes back.
400
- * No-op if the entry doesn't exist or the version hasn't changed.
401
- */
402
- export function updateServiceGroupVersion(
403
- assistantId: string,
404
- version: string,
405
- ): void {
406
- const entries = readAssistants();
407
- const entry = entries.find((e) => e.assistantId === assistantId);
408
- if (!entry) return;
409
- if (entry.serviceGroupVersion === version) return;
410
- entry.serviceGroupVersion = version;
411
- writeAssistants(entries);
412
- }
413
-
414
401
  /**
415
402
  * Scan upward from `basePort` to find an available port. A port is considered
416
403
  * available when `probePort()` returns false (nothing listening). Scans up to
@@ -434,72 +421,47 @@ async function findAvailablePort(
434
421
 
435
422
  /**
436
423
  * Allocate an isolated set of resources for a named local instance.
437
- * The first local assistant uses the home directory with default ports.
438
- * Subsequent assistants are placed under
439
- * `~/.local/share/vellum/assistants/<name>/` with scanned ports.
424
+ * Every new local assistant is allocated under
425
+ * `$XDG_DATA_HOME/vellum{-env}/assistants/<name>/`. The legacy `~/.vellum/`
426
+ * path is only reached via existing lockfile entries from before this change
427
+ * — the read path honors whatever `resources.instanceDir` is stored, so
428
+ * production users' existing first-local assistants keep their `~/.vellum/`
429
+ * roots unchanged.
440
430
  */
441
431
  export async function allocateLocalResources(
442
432
  instanceName: string,
443
433
  ): Promise<LocalInstanceResources> {
444
- // First local assistant gets the home directory with default ports.
445
- // Respect BASE_DATA_DIR when set (e.g. in e2e tests) so the daemon,
446
- // gateway, and credential store all resolve paths under the same root.
447
- const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
448
- if (existingLocals.length === 0) {
449
- const baseDir = getBaseDir();
450
- const vellumDir = join(baseDir, ".vellum");
451
- return {
452
- instanceDir: baseDir,
453
- daemonPort: DEFAULT_DAEMON_PORT,
454
- gatewayPort: DEFAULT_GATEWAY_PORT,
455
- qdrantPort: DEFAULT_QDRANT_PORT,
456
- cesPort: DEFAULT_CES_PORT,
457
- pidFile: join(vellumDir, "vellum.pid"),
458
- };
459
- }
460
-
461
- const instanceDir = join(
462
- homedir(),
463
- ".local",
464
- "share",
465
- "vellum",
466
- "assistants",
467
- instanceName,
468
- );
434
+ const env = getCurrentEnvironment();
435
+ const instanceDir = join(getMultiInstanceDir(env), instanceName);
469
436
  mkdirSync(instanceDir, { recursive: true });
470
437
 
471
438
  // Collect ports already assigned to other local instances in the lockfile.
472
- // Even if those instances are stopped, we must avoid reusing their ports
473
- // to prevent binding collisions when both are woken.
474
439
  const reservedPorts: number[] = [];
475
440
  for (const entry of loadAllAssistants()) {
476
- if (entry.cloud !== "local") continue;
477
- if (entry.resources) {
478
- reservedPorts.push(
479
- entry.resources.daemonPort,
480
- entry.resources.gatewayPort,
481
- entry.resources.qdrantPort,
482
- entry.resources.cesPort,
483
- );
484
- }
441
+ if (entry.cloud !== "local" || !entry.resources) continue;
442
+ reservedPorts.push(
443
+ entry.resources.daemonPort,
444
+ entry.resources.gatewayPort,
445
+ entry.resources.qdrantPort,
446
+ entry.resources.cesPort,
447
+ );
485
448
  }
486
449
 
487
- // Allocate ports sequentially to avoid overlapping ranges assigning the
488
- // same port to multiple services (e.g. daemon 7821-7920 overlaps gateway 7830-7929).
489
- const daemonPort = await findAvailablePort(
490
- DEFAULT_DAEMON_PORT,
491
- reservedPorts,
492
- );
493
- const gatewayPort = await findAvailablePort(DEFAULT_GATEWAY_PORT, [
450
+ // Env-aware bases: non-prod envs sit in their own 1000-port window so
451
+ // running prod and staging assistants side-by-side doesn't collide. See
452
+ // `environments/seeds.ts:portBlock` for the layout.
453
+ const basePorts = getDefaultPorts(env);
454
+ const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
455
+ const gatewayPort = await findAvailablePort(basePorts.gateway, [
494
456
  ...reservedPorts,
495
457
  daemonPort,
496
458
  ]);
497
- const qdrantPort = await findAvailablePort(DEFAULT_QDRANT_PORT, [
459
+ const qdrantPort = await findAvailablePort(basePorts.qdrant, [
498
460
  ...reservedPorts,
499
461
  daemonPort,
500
462
  gatewayPort,
501
463
  ]);
502
- const cesPort = await findAvailablePort(DEFAULT_CES_PORT, [
464
+ const cesPort = await findAvailablePort(basePorts.ces, [
503
465
  ...reservedPorts,
504
466
  daemonPort,
505
467
  gatewayPort,
@@ -516,6 +478,18 @@ export async function allocateLocalResources(
516
478
  };
517
479
  }
518
480
 
481
+ /**
482
+ * Return `platformBaseUrl` from the lockfile, if set. This is the value
483
+ * persisted by {@link syncConfigToLockfile} the last time the active
484
+ * assistant was hatched/waked, and is the source of truth for "which
485
+ * platform does the currently-active assistant target".
486
+ */
487
+ export function getLockfilePlatformBaseUrl(): string | undefined {
488
+ const url = readLockfile().platformBaseUrl;
489
+ if (typeof url === "string" && url.trim()) return url.trim();
490
+ return undefined;
491
+ }
492
+
519
493
  /**
520
494
  * Read the assistant config file and sync client-relevant values to the
521
495
  * lockfile. This lets external tools (e.g. vel) discover the platform URL
package/src/lib/aws.ts CHANGED
@@ -411,7 +411,18 @@ export async function hatchAws(
411
411
  }
412
412
  }
413
413
 
414
- const sshUser = userInfo().username;
414
+ let sshUser: string;
415
+ try {
416
+ sshUser = userInfo().username;
417
+ } catch {
418
+ sshUser = process.env.USER ?? "";
419
+ }
420
+ if (!sshUser) {
421
+ console.error(
422
+ "Error: Could not determine SSH username. Set the USER environment variable and try again.",
423
+ );
424
+ process.exit(1);
425
+ }
415
426
  const hatchedBy = process.env.VELLUM_HATCHED_BY;
416
427
  const providerApiKeys: Record<string, string> = {};
417
428
  for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
@@ -5,8 +5,8 @@ import { join } from "path";
5
5
  /**
6
6
  * Convert flat dot-notation key=value pairs into a nested config object.
7
7
  *
8
- * e.g. {"services.inference.provider": "anthropic", "services.inference.model": "claude-opus-4-6"}
9
- * → {services: {inference: {provider: "anthropic", model: "claude-opus-4-6"}}}
8
+ * e.g. {"llm.default.provider": "anthropic", "llm.default.model": "claude-opus-4-6"}
9
+ * → {llm: {default: {provider: "anthropic", model: "claude-opus-4-6"}}}
10
10
  */
11
11
  export function buildNestedConfig(
12
12
  configValues: Record<string, string>,
@@ -39,8 +39,8 @@ export function buildNestedConfig(
39
39
  * values into its workspace config on first boot.
40
40
  *
41
41
  * Keys use dot-notation to address nested fields. For example:
42
- * "services.inference.provider" → {services: {inference: {provider: ...}}}
43
- * "services.inference.model" → {services: {inference: {model: ...}}}
42
+ * "llm.default.provider" → {llm: {default: {provider: ...}}}
43
+ * "llm.default.model" → {llm: {default: {model: ...}}}
44
44
  *
45
45
  * Returns undefined when configValues is empty (nothing to write).
46
46
  */
@@ -16,16 +16,6 @@ export const DEFAULT_GATEWAY_PORT = 7830;
16
16
  export const DEFAULT_QDRANT_PORT = 6333;
17
17
  export const DEFAULT_CES_PORT = 8090;
18
18
 
19
- /**
20
- * Lockfile candidate filenames, checked in priority order.
21
- * `.vellum.lock.json` is the current name; `.vellum.lockfile.json` is the
22
- * legacy name kept for backwards compatibility with older installs.
23
- */
24
- export const LOCKFILE_NAMES = [
25
- ".vellum.lock.json",
26
- ".vellum.lockfile.json",
27
- ] as const;
28
-
29
19
  export const VALID_REMOTE_HOSTS = [
30
20
  "local",
31
21
  "gcp",