@vellumai/cli 0.6.2 → 0.6.4

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 (50) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bunfig.toml +6 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-config.test.ts +124 -0
  6. package/src/__tests__/env-drift.test.ts +87 -0
  7. package/src/__tests__/guardian-token.test.ts +172 -0
  8. package/src/__tests__/multi-local.test.ts +61 -14
  9. package/src/__tests__/orphan-detection.test.ts +214 -0
  10. package/src/__tests__/platform-client.test.ts +204 -0
  11. package/src/__tests__/preload.ts +27 -0
  12. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  13. package/src/__tests__/teleport.test.ts +1073 -57
  14. package/src/commands/backup.ts +8 -0
  15. package/src/commands/hatch.ts +5 -28
  16. package/src/commands/login.ts +178 -9
  17. package/src/commands/logs.ts +652 -0
  18. package/src/commands/pair.ts +9 -1
  19. package/src/commands/ps.ts +37 -7
  20. package/src/commands/recover.ts +8 -4
  21. package/src/commands/restore.ts +124 -12
  22. package/src/commands/retire.ts +17 -3
  23. package/src/commands/rollback.ts +32 -33
  24. package/src/commands/sleep.ts +7 -0
  25. package/src/commands/ssh-apple-container.ts +162 -0
  26. package/src/commands/ssh.ts +7 -0
  27. package/src/commands/teleport.ts +307 -3
  28. package/src/commands/upgrade.ts +43 -52
  29. package/src/commands/wake.ts +21 -10
  30. package/src/components/DefaultMainScreen.tsx +7 -1
  31. package/src/index.ts +3 -0
  32. package/src/lib/__tests__/docker.test.ts +78 -0
  33. package/src/lib/assistant-config.ts +54 -87
  34. package/src/lib/aws.ts +12 -1
  35. package/src/lib/constants.ts +0 -10
  36. package/src/lib/docker.ts +73 -4
  37. package/src/lib/environments/__tests__/paths.test.ts +234 -0
  38. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  39. package/src/lib/environments/paths.ts +110 -0
  40. package/src/lib/environments/resolve.ts +96 -0
  41. package/src/lib/environments/seeds.ts +46 -0
  42. package/src/lib/environments/types.ts +60 -0
  43. package/src/lib/gcp.ts +12 -1
  44. package/src/lib/guardian-token.ts +8 -10
  45. package/src/lib/hatch-local.ts +30 -35
  46. package/src/lib/local.ts +46 -5
  47. package/src/lib/orphan-detection.ts +28 -12
  48. package/src/lib/platform-client.ts +261 -25
  49. package/src/lib/retire-apple-container.ts +102 -0
  50. package/src/lib/upgrade-lifecycle.ts +101 -28
@@ -0,0 +1,110 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+
4
+ import type { EnvironmentDefinition, PortMap } from "./types.js";
5
+
6
+ const PRODUCTION_ENVIRONMENT_NAME = "production";
7
+
8
+ /**
9
+ * Production lockfile filenames in priority order. The current name is
10
+ * `.vellum.lock.json`; `.vellum.lockfile.json` is the legacy name kept for
11
+ * backward compatibility with installs that predate the rename.
12
+ */
13
+ const PRODUCTION_LOCKFILE_NAMES = [
14
+ ".vellum.lock.json",
15
+ ".vellum.lockfile.json",
16
+ ] as const;
17
+
18
+ const DEFAULT_PORTS: Readonly<PortMap> = {
19
+ daemon: 7821,
20
+ gateway: 7830,
21
+ qdrant: 6333,
22
+ ces: 8090,
23
+ outboundProxy: 8080,
24
+ tcp: 8765,
25
+ };
26
+
27
+ /**
28
+ * Config directory for an environment.
29
+ * Production preserves the existing `~/.config/vellum/` location;
30
+ * non-production environments use `$XDG_CONFIG_HOME/vellum-<env>/`.
31
+ */
32
+ export function getConfigDir(env: EnvironmentDefinition): string {
33
+ if (env.configDirOverride) return env.configDirOverride;
34
+ if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
35
+ return join(xdgConfigHome(), "vellum");
36
+ }
37
+ return join(xdgConfigHome(), `vellum-${env.name}`);
38
+ }
39
+
40
+ /**
41
+ * Lockfile candidate paths for an environment, in priority order.
42
+ *
43
+ * For production, returns both the current `.vellum.lock.json` and the
44
+ * legacy `.vellum.lockfile.json` so read-side callers can fall back to the
45
+ * legacy filename on installs that predate the rename. Non-production
46
+ * environments are new and have a single canonical path under the env-scoped
47
+ * XDG config directory.
48
+ *
49
+ * Read-side callers should iterate this array and use the first existing
50
+ * file (matching `cli/src/lib/assistant-config.ts:readLockfile`). Write-side
51
+ * callers should use {@link getLockfilePath}, which returns the first
52
+ * (canonical) entry.
53
+ *
54
+ * `env.lockfileDirOverride` (populated by the resolver from
55
+ * `VELLUM_LOCKFILE_DIR`) overrides the directory the lockfile lives in for
56
+ * both production and non-production environments.
57
+ */
58
+ export function getLockfilePaths(env: EnvironmentDefinition): string[] {
59
+ if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
60
+ const dir = env.lockfileDirOverride ?? homedir();
61
+ return PRODUCTION_LOCKFILE_NAMES.map((name) => join(dir, name));
62
+ }
63
+ const dir = env.lockfileDirOverride ?? getConfigDir(env);
64
+ return [join(dir, "lockfile.json")];
65
+ }
66
+
67
+ /**
68
+ * Canonical lockfile path for writes. For production this is the current
69
+ * `.vellum.lock.json` (legacy reads handled by {@link getLockfilePaths}).
70
+ */
71
+ export function getLockfilePath(env: EnvironmentDefinition): string {
72
+ return getLockfilePaths(env)[0]!;
73
+ }
74
+
75
+ /**
76
+ * Multi-instance root directory for an environment. Production uses
77
+ * `~/.local/share/vellum/assistants/` — the convention already in
78
+ * `cli/src/lib/assistant-config.ts`. Non-production environments use
79
+ * `~/.local/share/vellum-<env>/assistants/`.
80
+ */
81
+ export function getMultiInstanceDir(env: EnvironmentDefinition): string {
82
+ if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
83
+ return join(xdgDataHome(), "vellum", "assistants");
84
+ }
85
+ return join(xdgDataHome(), `vellum-${env.name}`, "assistants");
86
+ }
87
+
88
+ /**
89
+ * Default port set for an environment. Phase 5 (per-env port offsets) was
90
+ * deferred from MVP — this currently returns the same ports for every
91
+ * environment. Per-env specialization lands in a later phase without
92
+ * changing the function signature or call sites. `env.portsOverride` is
93
+ * merged on top of the defaults when set.
94
+ */
95
+ export function getDefaultPorts(env: EnvironmentDefinition): PortMap {
96
+ return {
97
+ ...DEFAULT_PORTS,
98
+ ...(env.portsOverride ?? {}),
99
+ };
100
+ }
101
+
102
+ function xdgDataHome(): string {
103
+ return (
104
+ process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share")
105
+ );
106
+ }
107
+
108
+ function xdgConfigHome(): string {
109
+ return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
110
+ }
@@ -0,0 +1,96 @@
1
+ import { SEEDS } from "./seeds.js";
2
+ import type { EnvironmentDefinition } from "./types.js";
3
+
4
+ const DEFAULT_ENVIRONMENT_NAME = "production";
5
+
6
+ /**
7
+ * Look up a seed entry by name. Returns `undefined` if no seed matches.
8
+ * Callers that need the full resolution stack (env-var overrides, default
9
+ * fallback, error on unknown) should use {@link getCurrentEnvironment}
10
+ * instead. The returned definition is a shallow copy so mutations by the
11
+ * caller don't leak back into the seed table.
12
+ */
13
+ export function getSeed(name: string): EnvironmentDefinition | undefined {
14
+ const seed = SEEDS[name];
15
+ if (!seed) return undefined;
16
+ return { ...seed };
17
+ }
18
+
19
+ /**
20
+ * Resolve the current environment definition.
21
+ *
22
+ * Priority:
23
+ * 1. `override` argument (from a `--environment` CLI flag, when wired)
24
+ * 2. `VELLUM_ENVIRONMENT` env var
25
+ * 3. (future) user context file
26
+ * 4. Default: `production`
27
+ *
28
+ * Per-field env-var overrides are honored on the resolved definition as
29
+ * ad-hoc escape hatches (they do not materialize new environments):
30
+ * - `VELLUM_PLATFORM_URL` overrides `platformUrl`
31
+ * - `VELLUM_ASSISTANT_PLATFORM_URL` overrides `assistantPlatformUrl`
32
+ * - `VELLUM_LOCKFILE_DIR` overrides `lockfileDirOverride` (legacy e2e
33
+ * test hook)
34
+ *
35
+ * This function should be the single entrypoint for environment resolution.
36
+ * No other code should drive off `VELLUM_ENVIRONMENT` directly.
37
+ */
38
+ export function getCurrentEnvironment(
39
+ override?: string,
40
+ ): EnvironmentDefinition {
41
+ const name = resolveEnvironmentName(override);
42
+ const seed = SEEDS[name];
43
+ if (!seed) {
44
+ if (name !== DEFAULT_ENVIRONMENT_NAME) {
45
+ // Warn on stderr instead of throwing, to match the silent-fallback
46
+ // behavior in assistant/src/util/platform.ts:getXdgVellumConfigDirName
47
+ // and clients/shared/App/VellumEnvironment.swift:current. Those two
48
+ // silently fall back to production; the CLI should agree so all three
49
+ // writers don't end up in disjoint states on a typo.
50
+ process.stderr.write(
51
+ `warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
52
+ `Add it to cli/src/lib/environments/seeds.ts and rebuild if this was intentional.\n`,
53
+ );
54
+ }
55
+ const fallback = SEEDS[DEFAULT_ENVIRONMENT_NAME];
56
+ if (!fallback) {
57
+ throw new Error(
58
+ `fatal: default environment "${DEFAULT_ENVIRONMENT_NAME}" missing from seed table — this is a build error`,
59
+ );
60
+ }
61
+ return { ...fallback };
62
+ }
63
+
64
+ const resolved: EnvironmentDefinition = { ...seed };
65
+
66
+ const platformUrlOverride = process.env.VELLUM_PLATFORM_URL?.trim();
67
+ if (platformUrlOverride) {
68
+ resolved.platformUrl = platformUrlOverride;
69
+ }
70
+
71
+ const assistantPlatformUrlOverride =
72
+ process.env.VELLUM_ASSISTANT_PLATFORM_URL?.trim();
73
+ if (assistantPlatformUrlOverride) {
74
+ resolved.assistantPlatformUrl = assistantPlatformUrlOverride;
75
+ }
76
+
77
+ const lockfileDirOverride = process.env.VELLUM_LOCKFILE_DIR?.trim();
78
+ if (lockfileDirOverride) {
79
+ resolved.lockfileDirOverride = lockfileDirOverride;
80
+ }
81
+
82
+ return resolved;
83
+ }
84
+
85
+ function resolveEnvironmentName(override: string | undefined): string {
86
+ const trimmedOverride = override?.trim();
87
+ if (trimmedOverride && trimmedOverride.length > 0) {
88
+ return trimmedOverride;
89
+ }
90
+ const envVar = process.env.VELLUM_ENVIRONMENT?.trim();
91
+ if (envVar && envVar.length > 0) {
92
+ return envVar;
93
+ }
94
+
95
+ return DEFAULT_ENVIRONMENT_NAME;
96
+ }
@@ -0,0 +1,46 @@
1
+ import type { EnvironmentDefinition } from "./types.js";
2
+
3
+ /**
4
+ * Built-in environment definitions. Mirrors Swift's
5
+ * `clients/macos/vellum-assistant/App/VellumEnvironment.swift` enum and is
6
+ * the TS-side source of truth for the set of known environment names.
7
+ * Two other TS sites duplicate the name list:
8
+ * - `assistant/src/util/platform.ts` (`KNOWN_ENVIRONMENTS`)
9
+ * - `clients/chrome-extension/native-host/src/lockfile.ts`
10
+ * (`NON_PRODUCTION_ENVIRONMENTS`, excludes `production`)
11
+ * Drift between these three sites is caught at test time by
12
+ * `cli/src/__tests__/env-drift.test.ts`. Fast follow: hoist the shared
13
+ * list into a `packages/environments` package so all three sites import
14
+ * from one place.
15
+ *
16
+ * Custom environments via a user config file are a future phase — see the
17
+ * "Coexisting environments" design doc. Until then, a call site that needs a
18
+ * new environment must add it here and rebuild.
19
+ */
20
+ export const SEEDS: Record<string, EnvironmentDefinition> = {
21
+ production: {
22
+ name: "production",
23
+ platformUrl: "https://platform.vellum.ai",
24
+ },
25
+ staging: {
26
+ name: "staging",
27
+ platformUrl: "https://staging-platform.vellum.ai",
28
+ },
29
+ test: {
30
+ name: "test",
31
+ // Non-functional URL — used only by unit tests for URL resolution, never
32
+ // hit in production.
33
+ platformUrl: "https://test-platform.vellum.ai",
34
+ },
35
+ dev: {
36
+ name: "dev",
37
+ platformUrl: "https://dev-platform.vellum.ai",
38
+ },
39
+ local: {
40
+ name: "local",
41
+ platformUrl: "http://localhost:8000",
42
+ // assistantPlatformUrl: "http://host.docker.internal:8000",
43
+ // ^ uncomment this once dockerized hatch path is live.
44
+ // The assistant runs in a different network namespace than the host.
45
+ },
46
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Environment type definitions. Environments are deployment targets with
3
+ * their own platform backend and their own isolated on-host state. See the
4
+ * "Coexisting environments" design doc for the full model.
5
+ */
6
+
7
+ /**
8
+ * Per-service default port set. Phase 5 (per-environment port offsets) is
9
+ * deferred from MVP, so today every environment uses the same port set. The
10
+ * shape exists so the rest of the stack can call `getDefaultPorts(env)` and
11
+ * gain per-env offsets later without changing any call sites.
12
+ */
13
+ export interface PortMap {
14
+ daemon: number;
15
+ gateway: number;
16
+ qdrant: number;
17
+ ces: number;
18
+ outboundProxy: number;
19
+ tcp: number;
20
+ }
21
+
22
+ /**
23
+ * A resolved environment definition. Required fields are `name` and
24
+ * `platformUrl`. All other fields are optional and declared upfront — new
25
+ * fields are additive, never breaking. `name` is intentionally typed as
26
+ * `string` (not `keyof SEEDS`) so custom environments can be represented by
27
+ * future layers (user config file, ad-hoc env vars, etc.).
28
+ */
29
+ export interface EnvironmentDefinition {
30
+ name: string;
31
+ platformUrl: string;
32
+
33
+ /**
34
+ * Override for the platform URL the assistant process itself uses. Only
35
+ * differs from `platformUrl` when the assistant runs in a different network
36
+ * namespace than the host (e.g. Docker on macOS, where the host's localhost
37
+ * is reached via `host.docker.internal`). Falls back to `platformUrl` when
38
+ * unset.
39
+ */
40
+ assistantPlatformUrl?: string;
41
+
42
+ /** Human-readable label for UI surfaces. */
43
+ displayName?: string;
44
+
45
+ /** Hint for UI surfaces that want to tint or badge their display. */
46
+ tintColor?: string;
47
+
48
+ /** Per-service port overrides merged on top of defaults. */
49
+ portsOverride?: Partial<PortMap>;
50
+
51
+ /** Override for the XDG config directory. */
52
+ configDirOverride?: string;
53
+
54
+ /**
55
+ * Override for the directory containing the lockfile. Populated by the
56
+ * resolver from `VELLUM_LOCKFILE_DIR` (an existing e2e test escape hatch)
57
+ * so path helpers don't read env vars directly.
58
+ */
59
+ lockfileDirOverride?: string;
60
+ }
package/src/lib/gcp.ts CHANGED
@@ -503,7 +503,18 @@ export async function hatchGcp(
503
503
  }
504
504
  }
505
505
 
506
- const sshUser = userInfo().username;
506
+ let sshUser: string;
507
+ try {
508
+ sshUser = userInfo().username;
509
+ } catch {
510
+ sshUser = process.env.USER ?? "";
511
+ }
512
+ if (!sshUser) {
513
+ console.error(
514
+ "Error: Could not determine SSH username. Set the USER environment variable and try again.",
515
+ );
516
+ process.exit(1);
517
+ }
507
518
  const hatchedBy = process.env.VELLUM_HATCHED_BY;
508
519
  const providerApiKeys: Record<string, string> = {};
509
520
  for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
@@ -7,9 +7,12 @@ import {
7
7
  readFileSync,
8
8
  writeFileSync,
9
9
  } from "fs";
10
- import { homedir, platform } from "os";
10
+ import { platform } from "os";
11
11
  import { dirname, join } from "path";
12
12
 
13
+ import { getConfigDir } from "./environments/paths.js";
14
+ import { getCurrentEnvironment } from "./environments/resolve.js";
15
+
13
16
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
14
17
 
15
18
  export interface GuardianTokenData {
@@ -24,14 +27,9 @@ export interface GuardianTokenData {
24
27
  leasedAt: string;
25
28
  }
26
29
 
27
- function getXdgConfigHome(): string {
28
- return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
29
- }
30
-
31
30
  function getGuardianTokenPath(assistantId: string): string {
32
31
  return join(
33
- getXdgConfigHome(),
34
- "vellum",
32
+ getConfigDir(getCurrentEnvironment()),
35
33
  "assistants",
36
34
  assistantId,
37
35
  "guardian-token.json",
@@ -39,7 +37,7 @@ function getGuardianTokenPath(assistantId: string): string {
39
37
  }
40
38
 
41
39
  function getPersistedDeviceIdPath(): string {
42
- return join(getXdgConfigHome(), "vellum", "device-id");
40
+ return join(getConfigDir(getCurrentEnvironment()), "device-id");
43
41
  }
44
42
 
45
43
  function hashWithSalt(input: string): string {
@@ -82,7 +80,7 @@ function getWindowsMachineGuid(): string | null {
82
80
  }
83
81
  }
84
82
 
85
- function getOrCreatePersistedDeviceId(): string {
83
+ export function getOrCreatePersistedDeviceId(): string {
86
84
  const path = getPersistedDeviceIdPath();
87
85
  try {
88
86
  const existing = readFileSync(path, "utf-8").trim();
@@ -161,7 +159,7 @@ export function saveGuardianToken(
161
159
  /**
162
160
  * Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
163
161
  * credential pair. The returned tokens are persisted locally under
164
- * `$XDG_CONFIG_HOME/vellum/assistants/<assistantId>/guardian-token.json`.
162
+ * `$XDG_CONFIG_HOME/vellum{-env}/assistants/<assistantId>/guardian-token.json`.
165
163
  */
166
164
  export async function leaseGuardianToken(
167
165
  gatewayUrl: string,
@@ -144,18 +144,10 @@ function installCLISymlink(): void {
144
144
  export async function hatchLocal(
145
145
  species: Species,
146
146
  name: string | null,
147
- restart: boolean = false,
148
147
  watch: boolean = false,
149
148
  keepAlive: boolean = false,
150
149
  configValues: Record<string, string> = {},
151
150
  ): Promise<void> {
152
- if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
153
- console.error(
154
- "Error: Cannot restart without a known assistant ID. Provide --name or ensure VELLUM_ASSISTANT_NAME is set.",
155
- );
156
- process.exit(1);
157
- }
158
-
159
151
  const instanceName = generateInstanceName(
160
152
  species,
161
153
  name ?? process.env.VELLUM_ASSISTANT_NAME,
@@ -320,20 +312,9 @@ export async function hatchLocal(
320
312
  }
321
313
 
322
314
  // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
323
- // Set BASE_DATA_DIR so ngrok reads the correct instance config.
324
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
325
- process.env.BASE_DATA_DIR = resources.instanceDir;
326
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
327
- if (ngrokChild?.pid) {
328
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
329
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
330
- }
331
- if (prevBaseDataDir !== undefined) {
332
- process.env.BASE_DATA_DIR = prevBaseDataDir;
333
- } else {
334
- delete process.env.BASE_DATA_DIR;
335
- }
336
-
315
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config. Keep the
316
+ // lockfile save/sync inside the same scope so syncConfigToLockfile() reads
317
+ // this instance's workspace/config.json rather than a stale default path.
337
318
  const localEntry: AssistantEntry = {
338
319
  assistantId: instanceName,
339
320
  runtimeUrl,
@@ -341,28 +322,42 @@ export async function hatchLocal(
341
322
  cloud: "local",
342
323
  species,
343
324
  hatchedAt: new Date().toISOString(),
344
- serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
345
325
  resources: { ...resources, signingKey },
346
326
  };
347
- emitProgress(7, 7, "Saving configuration...");
348
- if (!restart) {
327
+
328
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
329
+ process.env.BASE_DATA_DIR = resources.instanceDir;
330
+ try {
331
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
332
+ if (ngrokChild?.pid) {
333
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
334
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
335
+ }
336
+
337
+ emitProgress(7, 7, "Saving configuration...");
349
338
  saveAssistantEntry(localEntry);
350
339
  setActiveAssistant(instanceName);
351
340
  syncConfigToLockfile();
352
-
353
- if (process.env.VELLUM_DESKTOP_APP) {
354
- installCLISymlink();
341
+ } finally {
342
+ if (prevBaseDataDir !== undefined) {
343
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
344
+ } else {
345
+ delete process.env.BASE_DATA_DIR;
355
346
  }
347
+ }
356
348
 
357
- console.log("");
358
- console.log(`✅ Local assistant hatched!`);
359
- console.log("");
360
- console.log("Instance details:");
361
- console.log(` Name: ${instanceName}`);
362
- console.log(` Runtime: ${runtimeUrl}`);
363
- console.log("");
349
+ if (process.env.VELLUM_DESKTOP_APP) {
350
+ installCLISymlink();
364
351
  }
365
352
 
353
+ console.log("");
354
+ console.log(`✅ Local assistant hatched!`);
355
+ console.log("");
356
+ console.log("Instance details:");
357
+ console.log(` Name: ${instanceName}`);
358
+ console.log(` Runtime: ${runtimeUrl}`);
359
+ console.log("");
360
+
366
361
  if (keepAlive) {
367
362
  const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
368
363
  const healthTarget = "Gateway";
package/src/lib/local.ts CHANGED
@@ -283,12 +283,18 @@ async function startDaemonFromSource(
283
283
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
284
284
  VELLUM_CLOUD: "local",
285
285
  VELLUM_DEV: "1",
286
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
286
287
  ...(options?.signingKey
287
288
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
288
289
  : {}),
289
290
  };
290
291
  if (resources) {
291
292
  env.BASE_DATA_DIR = resources.instanceDir;
293
+ env.GATEWAY_SECURITY_DIR = join(
294
+ resources.instanceDir,
295
+ ".vellum",
296
+ "protected",
297
+ );
292
298
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
293
299
  env.GATEWAY_PORT = String(resources.gatewayPort);
294
300
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -404,12 +410,18 @@ async function startDaemonWatchFromSource(
404
410
  ...process.env,
405
411
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
406
412
  VELLUM_DEV: "1",
413
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
407
414
  ...(options?.signingKey
408
415
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
409
416
  : {}),
410
417
  };
411
418
  if (resources) {
412
419
  env.BASE_DATA_DIR = resources.instanceDir;
420
+ env.GATEWAY_SECURITY_DIR = join(
421
+ resources.instanceDir,
422
+ ".vellum",
423
+ "protected",
424
+ );
413
425
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
414
426
  env.GATEWAY_PORT = String(resources.gatewayPort);
415
427
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -855,11 +867,16 @@ export async function startLocalDaemon(
855
867
  HOME: process.env.HOME || home,
856
868
  PATH: [...extraDirs, basePath].filter(Boolean).join(":"),
857
869
  };
858
- // Forward optional config env vars the daemon may need
870
+ // Forward optional config env vars the daemon may need.
871
+ // `VELLUM_ENVIRONMENT` must be forwarded so the daemon resolves
872
+ // env-scoped paths (device ID, platform/guardian tokens, XDG
873
+ // config dir) to the same location as the CLI that spawned it.
859
874
  for (const key of [
860
875
  "ANTHROPIC_API_KEY",
861
876
  "APP_VERSION",
862
877
  "BASE_DATA_DIR",
878
+ "GATEWAY_SECURITY_DIR",
879
+ "VELLUM_ENVIRONMENT",
863
880
  "VELLUM_PLATFORM_URL",
864
881
  "QDRANT_HTTP_PORT",
865
882
  "QDRANT_URL",
@@ -885,6 +902,11 @@ export async function startLocalDaemon(
885
902
  // all paths under the instance directory and listens on its own port.
886
903
  if (resources) {
887
904
  daemonEnv.BASE_DATA_DIR = resources.instanceDir;
905
+ daemonEnv.GATEWAY_SECURITY_DIR = join(
906
+ resources.instanceDir,
907
+ ".vellum",
908
+ "protected",
909
+ );
888
910
  daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
889
911
  daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
890
912
  daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -1043,10 +1065,29 @@ export async function startGateway(
1043
1065
  ...(options?.signingKey
1044
1066
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
1045
1067
  : {}),
1046
- ...(watch ? { VELLUM_DEV: "1" } : {}),
1047
- // Set BASE_DATA_DIR so the gateway loads the correct credentials and
1048
- // workspace config for this instance (mirrors the daemon env setup).
1049
- ...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
1068
+ ...(watch
1069
+ ? {
1070
+ VELLUM_DEV: "1",
1071
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
1072
+ }
1073
+ : {}),
1074
+ // Set VELLUM_WORKSPACE_DIR and GATEWAY_SECURITY_DIR so the gateway
1075
+ // loads the correct credentials and workspace config for this instance
1076
+ // (mirrors the daemon env setup).
1077
+ ...(resources
1078
+ ? {
1079
+ VELLUM_WORKSPACE_DIR: join(
1080
+ resources.instanceDir,
1081
+ ".vellum",
1082
+ "workspace",
1083
+ ),
1084
+ GATEWAY_SECURITY_DIR: join(
1085
+ resources.instanceDir,
1086
+ ".vellum",
1087
+ "protected",
1088
+ ),
1089
+ }
1090
+ : {}),
1050
1091
  };
1051
1092
  if (publicUrl) {
1052
1093
  console.log(` Ingress URL: ${publicUrl}`);
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
+ import { loadAllAssistants } from "./assistant-config.js";
5
6
  import { execOutput } from "./step-runner";
6
7
 
7
8
  export interface RemoteProcess {
@@ -72,20 +73,35 @@ export interface OrphanedProcess {
72
73
  export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
73
74
  const results: OrphanedProcess[] = [];
74
75
  const seenPids = new Set<string>();
75
- const vellumDir = join(homedir(), ".vellum");
76
76
 
77
- // Strategy 1: PID file scan
78
- const pidFiles: Array<{ file: string; name: string }> = [
79
- { file: join(vellumDir, "vellum.pid"), name: "assistant" },
80
- { file: join(vellumDir, "gateway.pid"), name: "gateway" },
81
- { file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
82
- ];
77
+ // Collect every known local instance's `.vellum/` directory from the
78
+ // lockfile so orphan detection scans all containers under the current
79
+ // multi-instance data layout, not just the legacy `~/.vellum/` root.
80
+ const dirs = new Set<string>();
81
+ for (const entry of loadAllAssistants()) {
82
+ if (entry.cloud !== "local" || !entry.resources) continue;
83
+ dirs.add(join(entry.resources.instanceDir, ".vellum"));
84
+ }
85
+ // Preserve the legacy root scan for installs that predate multi-instance
86
+ // tracking. This catches orphans from a pre-upgrade `~/.vellum/` that
87
+ // may not have a lockfile entry at all.
88
+ dirs.add(join(homedir(), ".vellum"));
89
+
90
+ // Strategy 1: PID file scan — check every known data directory.
91
+ for (const dir of dirs) {
92
+ const pidFiles: Array<{ file: string; name: string }> = [
93
+ { file: join(dir, "vellum.pid"), name: "assistant" },
94
+ { file: join(dir, "gateway.pid"), name: "gateway" },
95
+ { file: join(dir, "qdrant.pid"), name: "qdrant" },
96
+ ];
83
97
 
84
- for (const { file, name } of pidFiles) {
85
- const pid = readPidFile(file);
86
- if (pid && isProcessAlive(pid)) {
87
- results.push({ name, pid, source: "pid file" });
88
- seenPids.add(pid);
98
+ for (const { file, name } of pidFiles) {
99
+ const pid = readPidFile(file);
100
+ if (!pid || seenPids.has(pid)) continue;
101
+ if (isProcessAlive(pid)) {
102
+ results.push({ name, pid, source: "pid file" });
103
+ seenPids.add(pid);
104
+ }
89
105
  }
90
106
  }
91
107