@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
@@ -0,0 +1,231 @@
1
+ import type { AssistantEntry } from "./assistant-config.js";
2
+ import {
3
+ authHeaders,
4
+ parseUnifiedJobStatus,
5
+ type UnifiedJobStatus,
6
+ } from "./platform-client.js";
7
+ import { resolveRuntimeMigrationUrl } from "./runtime-url.js";
8
+
9
+ /**
10
+ * Thrown when the local runtime returns 409 for an export/import request
11
+ * because another migration of the same type is already in-flight. The
12
+ * caller can inspect {@link existingJobId} and decide whether to poll the
13
+ * existing job instead of retrying.
14
+ */
15
+ export class MigrationInProgressError extends Error {
16
+ readonly existingJobId: string;
17
+ readonly kind: "export_in_progress" | "import_in_progress";
18
+
19
+ constructor(
20
+ kind: "export_in_progress" | "import_in_progress",
21
+ jobId: string,
22
+ ) {
23
+ super(
24
+ `A migration is already in progress (${kind}); existing job_id=${jobId}`,
25
+ );
26
+ this.name = "MigrationInProgressError";
27
+ this.kind = kind;
28
+ this.existingJobId = jobId;
29
+ }
30
+ }
31
+
32
+ function bearerHeaders(token: string): Record<string, string> {
33
+ return {
34
+ Authorization: `Bearer ${token}`,
35
+ "Content-Type": "application/json",
36
+ Accept: "application/json",
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Build the auth + content headers for a runtime migration request.
42
+ *
43
+ * - For `cloud === "vellum"` we go through the platform's wildcard runtime
44
+ * proxy, which authenticates user-session / vak_ tokens via DRF's default
45
+ * authentication classes — `authHeaders()` produces the right combination
46
+ * (`X-Session-Token` + `Vellum-Organization-Id`, or `Authorization: Bearer
47
+ * vak_...`).
48
+ * - For local/docker the runtime endpoint expects a guardian-token bearer.
49
+ */
50
+ async function migrationRequestHeaders(
51
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl">,
52
+ token: string,
53
+ ): Promise<Record<string, string>> {
54
+ if (entry.cloud === "vellum") {
55
+ return {
56
+ ...(await authHeaders(token, entry.runtimeUrl)),
57
+ Accept: "application/json",
58
+ };
59
+ }
60
+ return bearerHeaders(token);
61
+ }
62
+
63
+ interface Raw409Body {
64
+ detail?: string;
65
+ // The runtime's current 409 contract nests the payload under `error`:
66
+ // { error: { code: "export_in_progress" | "import_in_progress", job_id } }
67
+ // We also tolerate a legacy flat shape ({ code, job_id }) for resilience.
68
+ error?: string | { code?: string; job_id?: string };
69
+ code?: string;
70
+ job_id?: string;
71
+ }
72
+
73
+ /** Common 409 → MigrationInProgressError parsing used by the two POST helpers. */
74
+ async function throwIfInProgress(
75
+ response: Response,
76
+ defaultKind: "export_in_progress" | "import_in_progress",
77
+ ): Promise<void> {
78
+ if (response.status !== 409) return;
79
+ const body = (await response.json().catch(() => ({}))) as Raw409Body;
80
+ const nested =
81
+ typeof body.error === "object" && body.error !== null
82
+ ? body.error
83
+ : undefined;
84
+ const jobId = nested?.job_id ?? body.job_id ?? "";
85
+ const rawKind =
86
+ nested?.code ??
87
+ body.code ??
88
+ (typeof body.error === "string" ? body.error : undefined) ??
89
+ defaultKind;
90
+ const kind: "export_in_progress" | "import_in_progress" =
91
+ rawKind === "export_in_progress" || rawKind === "import_in_progress"
92
+ ? rawKind
93
+ : defaultKind;
94
+ throw new MigrationInProgressError(kind, jobId);
95
+ }
96
+
97
+ /**
98
+ * Kick off an async export-to-GCS job on the assistant's runtime.
99
+ *
100
+ * For local/docker assistants this POSTs to
101
+ * `{runtimeUrl}/v1/migrations/export-to-gcs` with guardian-token bearer
102
+ * auth. For platform-managed (cloud="vellum") assistants the URL is rewritten
103
+ * to the wildcard-runtime-proxy shape
104
+ * `{platformUrl}/v1/assistants/<assistantId>/migrations/export-to-gcs` and
105
+ * authenticated via the platform-token header set the platform's DRF auth
106
+ * accepts (session / vak_).
107
+ *
108
+ * Returns the 202-accepted `job_id`. On 409 (another export in flight)
109
+ * throws {@link MigrationInProgressError} with the existing job_id.
110
+ */
111
+ export async function localRuntimeExportToGcs(
112
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
113
+ token: string,
114
+ params: { uploadUrl: string; description?: string },
115
+ ): Promise<{ jobId: string }> {
116
+ const body: Record<string, unknown> = { upload_url: params.uploadUrl };
117
+ if (params.description !== undefined) {
118
+ body.description = params.description;
119
+ }
120
+
121
+ const response = await fetch(
122
+ resolveRuntimeMigrationUrl(entry, "export-to-gcs"),
123
+ {
124
+ method: "POST",
125
+ headers: await migrationRequestHeaders(entry, token),
126
+ body: JSON.stringify(body),
127
+ },
128
+ );
129
+
130
+ await throwIfInProgress(response, "export_in_progress");
131
+
132
+ if (response.status !== 202) {
133
+ const errText = await response.text().catch(() => "");
134
+ throw new Error(
135
+ `Local runtime export-to-gcs failed (${response.status}): ${
136
+ errText || response.statusText
137
+ }`,
138
+ );
139
+ }
140
+
141
+ const json = (await response.json()) as {
142
+ job_id: string;
143
+ status?: string;
144
+ type?: string;
145
+ };
146
+ return { jobId: json.job_id };
147
+ }
148
+
149
+ /**
150
+ * Kick off an async import-from-GCS job on the assistant's runtime.
151
+ *
152
+ * For local/docker assistants this POSTs to
153
+ * `{runtimeUrl}/v1/migrations/import-from-gcs` with guardian-token bearer
154
+ * auth. For platform-managed (cloud="vellum") assistants the URL is rewritten
155
+ * to the wildcard-runtime-proxy shape
156
+ * `{platformUrl}/v1/assistants/<assistantId>/migrations/import-from-gcs` and
157
+ * authenticated via the platform token. On 409 throws
158
+ * {@link MigrationInProgressError}.
159
+ */
160
+ export async function localRuntimeImportFromGcs(
161
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
162
+ token: string,
163
+ params: { bundleUrl: string },
164
+ ): Promise<{ jobId: string }> {
165
+ const response = await fetch(
166
+ resolveRuntimeMigrationUrl(entry, "import-from-gcs"),
167
+ {
168
+ method: "POST",
169
+ headers: await migrationRequestHeaders(entry, token),
170
+ body: JSON.stringify({ bundle_url: params.bundleUrl }),
171
+ },
172
+ );
173
+
174
+ await throwIfInProgress(response, "import_in_progress");
175
+
176
+ if (response.status !== 202) {
177
+ const errText = await response.text().catch(() => "");
178
+ throw new Error(
179
+ `Local runtime import-from-gcs failed (${response.status}): ${
180
+ errText || response.statusText
181
+ }`,
182
+ );
183
+ }
184
+
185
+ const json = (await response.json()) as {
186
+ job_id: string;
187
+ status?: string;
188
+ type?: string;
189
+ };
190
+ return { jobId: json.job_id };
191
+ }
192
+
193
+ /**
194
+ * Poll the runtime's unified job-status endpoint.
195
+ *
196
+ * For local/docker assistants this GETs
197
+ * `{runtimeUrl}/v1/migrations/jobs/{jobId}` directly (guardian-token
198
+ * bearer). For platform-managed assistants it routes through the wildcard
199
+ * runtime proxy at
200
+ * `{platformUrl}/v1/assistants/<assistantId>/migrations/jobs/{jobId}` with
201
+ * platform-token auth — important: the platform's dedicated
202
+ * `/v1/migrations/jobs/{id}/` endpoint queries platform-side ImportJob
203
+ * records and would 404 on runtime-created job IDs.
204
+ */
205
+ export async function localRuntimePollJobStatus(
206
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
207
+ token: string,
208
+ jobId: string,
209
+ ): Promise<UnifiedJobStatus> {
210
+ const response = await fetch(
211
+ resolveRuntimeMigrationUrl(entry, `jobs/${jobId}`),
212
+ {
213
+ headers: await migrationRequestHeaders(entry, token),
214
+ },
215
+ );
216
+
217
+ if (response.status === 404) {
218
+ throw new Error("Migration job not found");
219
+ }
220
+
221
+ if (!response.ok) {
222
+ throw new Error(
223
+ `Local job status check failed: ${response.status} ${response.statusText}`,
224
+ );
225
+ }
226
+
227
+ const raw = (await response.json()) as Parameters<
228
+ typeof parseUnifiedJobStatus
229
+ >[0];
230
+ return parseUnifiedJobStatus(raw);
231
+ }
package/src/lib/local.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, execSync, spawn } from "child_process";
2
- import { randomBytes } from "crypto";
2
+ import { createHash, randomBytes } from "crypto";
3
3
  import {
4
4
  existsSync,
5
5
  mkdirSync,
@@ -8,10 +8,13 @@ import {
8
8
  writeFileSync,
9
9
  } from "fs";
10
10
  import { createRequire } from "module";
11
- import { homedir, hostname, networkInterfaces, platform } from "os";
11
+ import { homedir, networkInterfaces, platform, tmpdir } from "os";
12
12
  import { dirname, join } from "path";
13
13
 
14
- import { type LocalInstanceResources } from "./assistant-config.js";
14
+ import {
15
+ getDaemonPidPath,
16
+ type LocalInstanceResources,
17
+ } from "./assistant-config.js";
15
18
  import { GATEWAY_PORT } from "./constants.js";
16
19
  import { httpHealthCheck, waitForDaemonReady } from "./http-client.js";
17
20
  import { stopProcessByPidFile } from "./process.js";
@@ -19,6 +22,107 @@ import { openLogFile, pipeToLogFile } from "./xdg-log.js";
19
22
 
20
23
  const _require = createRequire(import.meta.url);
21
24
 
25
+ // macOS AF_UNIX path limit (sun_path is 104 bytes, null-terminated → 103 usable).
26
+ const DARWIN_UNIX_SOCKET_MAX_PATH_BYTES = 103;
27
+
28
+ // The longest socket filename we place in the workspace directory.
29
+ // assistant-skill.sock = 20 chars, plus 1 for the "/" separator = 21 overhead.
30
+ const LONGEST_SOCKET_FILENAME = "assistant-skill.sock";
31
+
32
+ /**
33
+ * Warn when an assistant appears to have legacy data in the global workspace.
34
+ *
35
+ * Old local startup paths could launch the daemon without
36
+ * `VELLUM_WORKSPACE_DIR`, causing writes to fall back to `~/.vellum/workspace`.
37
+ * New local instance launches pin the workspace under
38
+ * `<instanceDir>/.vellum/workspace`. If we detect data only in the legacy
39
+ * global path, warn with migration instructions so users are not surprised by
40
+ * missing history/settings after the fix.
41
+ */
42
+ function warnIfLegacyWorkspaceFallbackDetected(
43
+ resources: LocalInstanceResources,
44
+ ): void {
45
+ const instanceWorkspace = join(resources.instanceDir, ".vellum", "workspace");
46
+ const instanceDbPath = join(instanceWorkspace, "data", "db", "assistant.db");
47
+
48
+ const legacyWorkspace = join(homedir(), ".vellum", "workspace");
49
+ const legacyDbPath = join(legacyWorkspace, "data", "db", "assistant.db");
50
+
51
+ // Legacy "first local" entries use ~/.vellum directly; no drift possible.
52
+ if (instanceWorkspace === legacyWorkspace) return;
53
+
54
+ if (existsSync(legacyDbPath) && !existsSync(instanceDbPath)) {
55
+ console.warn("");
56
+ console.warn(
57
+ "WARNING: Detected legacy workspace data in ~/.vellum/workspace for this local assistant.",
58
+ );
59
+ console.warn(" What this means:");
60
+ console.warn(
61
+ " - An older startup path likely wrote assistant data to the global workspace.",
62
+ );
63
+ console.warn(
64
+ " - This assistant now uses its instance workspace instead:",
65
+ );
66
+ console.warn(` ${instanceWorkspace}`);
67
+ console.warn(" What to do:");
68
+ console.warn(
69
+ " 1. Stop the assistant before migrating files (retire/sleep or quit app).",
70
+ );
71
+ console.warn(
72
+ " 2. Copy needed data from ~/.vellum/workspace into the instance workspace.",
73
+ );
74
+ console.warn(
75
+ ` Example: cp -a ~/.vellum/workspace/data/db/assistant.db* ${join(instanceWorkspace, "data", "db")}/`,
76
+ );
77
+ console.warn(
78
+ " 3. Re-launch and confirm history/settings appear as expected.",
79
+ );
80
+ console.warn("");
81
+ }
82
+ }
83
+
84
+ /**
85
+ * On macOS, if `{workspaceDir}/assistant-skill.sock` would exceed the
86
+ * 103-byte AF_UNIX path limit, compute a short tmpdir-based IPC socket
87
+ * directory and return it. Returns `undefined` when no override is needed
88
+ * (the workspace path is short enough, or we're not on macOS).
89
+ */
90
+ function computeIpcSocketDirOverride(workspaceDir: string): string | undefined {
91
+ if (platform() !== "darwin") return undefined;
92
+
93
+ const longestPath = join(workspaceDir, LONGEST_SOCKET_FILENAME);
94
+ if (
95
+ Buffer.byteLength(longestPath, "utf8") <= DARWIN_UNIX_SOCKET_MAX_PATH_BYTES
96
+ ) {
97
+ return undefined;
98
+ }
99
+
100
+ // Use a short hash of the workspace dir so multiple instances get
101
+ // distinct socket directories under /tmp.
102
+ const hash = createHash("sha256")
103
+ .update(workspaceDir)
104
+ .digest("hex")
105
+ .slice(0, 12);
106
+ return join(tmpdir(), `vellum-ipc-${hash}`);
107
+ }
108
+
109
+ /**
110
+ * If the workspace path is too long for AF_UNIX sockets on macOS, compute
111
+ * a short override directory and set all IPC socket env vars on the target
112
+ * env object. No-op on non-macOS or when paths are within limits.
113
+ */
114
+ function applyIpcSocketDirOverride(env: Record<string, string>): void {
115
+ const workspaceDir =
116
+ env.VELLUM_WORKSPACE_DIR || join(homedir(), ".vellum", "workspace");
117
+ const override = computeIpcSocketDirOverride(workspaceDir);
118
+ if (!override) return;
119
+
120
+ mkdirSync(override, { recursive: true });
121
+ env.GATEWAY_IPC_SOCKET_DIR = override;
122
+ env.ASSISTANT_IPC_SOCKET_DIR = override;
123
+ env.ASSISTANT_SKILL_IPC_SOCKET_DIR = override;
124
+ }
125
+
22
126
  function isAssistantSourceDir(dir: string): boolean {
23
127
  const pkgPath = join(dir, "package.json");
24
128
  if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "index.ts")))
@@ -222,10 +326,9 @@ async function startDaemonFromSource(
222
326
  const daemonMainPath = resolveDaemonMainPath(assistantIndex);
223
327
 
224
328
  // Ensure the directory containing PID/socket files exists. For named
225
- // instances this is instanceDir/.vellum/ (matching daemon's getRootDir()).
226
- mkdirSync(dirname(resources.pidFile), { recursive: true });
227
-
228
- const pidFile = resources.pidFile;
329
+ // instances this is instanceDir/.vellum/workspace/ (matching daemon's getWorkspaceDir()).
330
+ const pidFile = getDaemonPidPath(resources);
331
+ mkdirSync(dirname(pidFile), { recursive: true });
229
332
 
230
333
  // --- Lifecycle guard: prevent split-brain daemon state ---
231
334
  if (existsSync(pidFile)) {
@@ -289,12 +392,21 @@ async function startDaemonFromSource(
289
392
  : {}),
290
393
  };
291
394
  if (resources) {
292
- env.BASE_DATA_DIR = resources.instanceDir;
395
+ env.VELLUM_WORKSPACE_DIR = join(
396
+ resources.instanceDir,
397
+ ".vellum",
398
+ "workspace",
399
+ );
293
400
  env.GATEWAY_SECURITY_DIR = join(
294
401
  resources.instanceDir,
295
402
  ".vellum",
296
403
  "protected",
297
404
  );
405
+ env.CREDENTIAL_SECURITY_DIR = join(
406
+ resources.instanceDir,
407
+ ".vellum",
408
+ "protected",
409
+ );
298
410
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
299
411
  env.GATEWAY_PORT = String(resources.gatewayPort);
300
412
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -349,9 +461,8 @@ async function startDaemonWatchFromSource(
349
461
  throw new Error(`Daemon main.ts not found at ${mainPath}`);
350
462
  }
351
463
 
352
- mkdirSync(dirname(resources.pidFile), { recursive: true });
353
-
354
- const pidFile = resources.pidFile;
464
+ const pidFile = getDaemonPidPath(resources);
465
+ mkdirSync(dirname(pidFile), { recursive: true });
355
466
 
356
467
  // --- Lifecycle guard: prevent split-brain daemon state ---
357
468
  // If a daemon is already running, skip spawning a new one.
@@ -416,12 +527,21 @@ async function startDaemonWatchFromSource(
416
527
  : {}),
417
528
  };
418
529
  if (resources) {
419
- env.BASE_DATA_DIR = resources.instanceDir;
530
+ env.VELLUM_WORKSPACE_DIR = join(
531
+ resources.instanceDir,
532
+ ".vellum",
533
+ "workspace",
534
+ );
420
535
  env.GATEWAY_SECURITY_DIR = join(
421
536
  resources.instanceDir,
422
537
  ".vellum",
423
538
  "protected",
424
539
  );
540
+ env.CREDENTIAL_SECURITY_DIR = join(
541
+ resources.instanceDir,
542
+ ".vellum",
543
+ "protected",
544
+ );
425
545
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
426
546
  env.GATEWAY_PORT = String(resources.gatewayPort);
427
547
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -563,51 +683,18 @@ export async function discoverPublicUrl(
563
683
  return `http://${cloudIp}:${effectivePort}`;
564
684
  }
565
685
 
566
- // Log the local address source only when we actually use it.
567
- if (localResult.source === "hostname") {
568
- console.log(` Discovered macOS local hostname: ${localResult.label}`);
569
- } else if (localResult.source === "lan") {
570
- console.log(` Discovered LAN IP: ${localResult.label}`);
571
- }
572
-
573
686
  return localResult.url;
574
687
  }
575
688
 
576
689
  /**
577
- * Resolve a LAN-reachable URL without any async I/O. Returns the best local
578
- * address or falls back to localhost. Does not emit any logs — the caller
579
- * decides whether to log based on which result is actually used.
690
+ * Returns the localhost URL for the gateway on the given port.
580
691
  */
581
692
  function discoverLocalUrl(effectivePort: number): {
582
693
  url: string;
583
- source: "hostname" | "lan" | "localhost";
584
- label?: string;
694
+ source: "localhost";
585
695
  } {
586
- // On macOS, prefer the .local hostname (Bonjour/mDNS) so other devices on
587
- // the same network can reach the gateway by name.
588
- if (platform() === "darwin") {
589
- const localHostname = getMacLocalHostname();
590
- if (localHostname) {
591
- return {
592
- url: `http://${localHostname}:${effectivePort}`,
593
- source: "hostname",
594
- label: localHostname,
595
- };
596
- }
597
- }
598
-
599
- const lanIp = getLocalLanIPv4();
600
- if (lanIp) {
601
- return {
602
- url: `http://${lanIp}:${effectivePort}`,
603
- source: "lan",
604
- label: lanIp,
605
- };
606
- }
607
-
608
- // Final fallback to localhost when no LAN address could be discovered.
609
696
  return {
610
- url: `http://localhost:${effectivePort}`,
697
+ url: `http://127.0.0.1:${effectivePort}`,
611
698
  source: "localhost",
612
699
  };
613
700
  }
@@ -664,19 +751,6 @@ async function discoverCloudExternalIp(): Promise<string | undefined> {
664
751
  return gcpIp ?? awsIp;
665
752
  }
666
753
 
667
- /**
668
- * Returns the macOS Bonjour/mDNS `.local` hostname (e.g. "Vargass-Mac-Mini.local"),
669
- * or undefined if not running on macOS or the hostname cannot be determined.
670
- */
671
- export function getMacLocalHostname(): string | undefined {
672
- const host = hostname();
673
- if (!host) return undefined;
674
- // macOS hostnames already end with .local when Bonjour is active
675
- if (host.endsWith(".local")) return host;
676
- // Otherwise, append .local — macOS resolves <ComputerName>.local via mDNS
677
- return `${host}.local`;
678
- }
679
-
680
754
  /**
681
755
  * Returns the local IPv4 address most likely to be reachable from other
682
756
  * devices on the same LAN.
@@ -769,6 +843,8 @@ export async function startLocalDaemon(
769
843
  resources: LocalInstanceResources,
770
844
  options?: DaemonStartOptions,
771
845
  ): Promise<void> {
846
+ warnIfLegacyWorkspaceFallbackDetected(resources);
847
+
772
848
  const foreground = options?.foreground ?? false;
773
849
  // Check for a compiled daemon binary adjacent to the CLI executable.
774
850
  // This covers both the desktop app (VELLUM_DESKTOP_APP) and the case where
@@ -779,7 +855,7 @@ export async function startLocalDaemon(
779
855
  // In watch mode, skip the bundled binary and use source (bun --watch
780
856
  // only works with source files, not compiled binaries).
781
857
 
782
- const pidFile = resources.pidFile;
858
+ const pidFile = getDaemonPidPath(resources);
783
859
 
784
860
  // If a daemon is already running, skip spawning a new one.
785
861
  // This prevents cascading kill→restart cycles when multiple callers
@@ -874,8 +950,8 @@ export async function startLocalDaemon(
874
950
  for (const key of [
875
951
  "ANTHROPIC_API_KEY",
876
952
  "APP_VERSION",
877
- "BASE_DATA_DIR",
878
953
  "GATEWAY_SECURITY_DIR",
954
+ "CREDENTIAL_SECURITY_DIR",
879
955
  "VELLUM_ENVIRONMENT",
880
956
  "VELLUM_PLATFORM_URL",
881
957
  "QDRANT_HTTP_PORT",
@@ -901,12 +977,21 @@ export async function startLocalDaemon(
901
977
  // When running a named instance, override env so the daemon resolves
902
978
  // all paths under the instance directory and listens on its own port.
903
979
  if (resources) {
904
- daemonEnv.BASE_DATA_DIR = resources.instanceDir;
980
+ daemonEnv.VELLUM_WORKSPACE_DIR = join(
981
+ resources.instanceDir,
982
+ ".vellum",
983
+ "workspace",
984
+ );
905
985
  daemonEnv.GATEWAY_SECURITY_DIR = join(
906
986
  resources.instanceDir,
907
987
  ".vellum",
908
988
  "protected",
909
989
  );
990
+ daemonEnv.CREDENTIAL_SECURITY_DIR = join(
991
+ resources.instanceDir,
992
+ ".vellum",
993
+ "protected",
994
+ );
910
995
  daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
911
996
  daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
912
997
  daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -917,6 +1002,8 @@ export async function startLocalDaemon(
917
1002
  daemonEnv.ACTOR_TOKEN_SIGNING_KEY = options.signingKey;
918
1003
  }
919
1004
 
1005
+ applyIpcSocketDirOverride(daemonEnv);
1006
+
920
1007
  // Write a sentinel PID file before spawning so concurrent hatch() calls
921
1008
  // see the file and fall through to the isDaemonResponsive() port check
922
1009
  // instead of racing to spawn a duplicate daemon.
@@ -1044,7 +1131,7 @@ export async function startGateway(
1044
1131
 
1045
1132
  const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
1046
1133
  if (publicUrl) {
1047
- console.log(` Public URL: ${publicUrl}`);
1134
+ console.log(` HTTP URL: ${publicUrl}`);
1048
1135
  }
1049
1136
 
1050
1137
  console.log("🌐 Starting gateway...");
@@ -1058,7 +1145,6 @@ export async function startGateway(
1058
1145
  GATEWAY_PORT: String(effectiveGatewayPort),
1059
1146
  // Pass gateway operational settings via env vars so the CLI does not
1060
1147
  // need direct access to the workspace config file.
1061
- RUNTIME_PROXY_ENABLED: "true",
1062
1148
  RUNTIME_PROXY_REQUIRE_AUTH: "true",
1063
1149
  UNMAPPED_POLICY: "default",
1064
1150
  DEFAULT_ASSISTANT_ID: "self",
@@ -1071,12 +1157,11 @@ export async function startGateway(
1071
1157
  VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
1072
1158
  }
1073
1159
  : {}),
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).
1160
+ // Pin gateway workspace/security paths to the named instance so parent
1161
+ // env vars cannot leak a different workspace. The gateway opens the
1162
+ // assistant DB directly for guardian bootstrap.
1077
1163
  ...(resources
1078
1164
  ? {
1079
- BASE_DATA_DIR: resources.instanceDir,
1080
1165
  VELLUM_WORKSPACE_DIR: join(
1081
1166
  resources.instanceDir,
1082
1167
  ".vellum",
@@ -1087,11 +1172,19 @@ export async function startGateway(
1087
1172
  ".vellum",
1088
1173
  "protected",
1089
1174
  ),
1175
+ CREDENTIAL_SECURITY_DIR: join(
1176
+ resources.instanceDir,
1177
+ ".vellum",
1178
+ "protected",
1179
+ ),
1090
1180
  }
1091
1181
  : {}),
1092
1182
  };
1183
+
1184
+ applyIpcSocketDirOverride(gatewayEnv);
1185
+
1093
1186
  if (publicUrl) {
1094
- console.log(` Ingress URL: ${publicUrl}`);
1187
+ console.log(` HTTP URL: ${publicUrl}`);
1095
1188
  }
1096
1189
 
1097
1190
  let gateway;
@@ -1186,7 +1279,7 @@ export async function stopLocalProcesses(
1186
1279
  const vellumDir = resources
1187
1280
  ? join(resources.instanceDir, ".vellum")
1188
1281
  : join(homedir(), ".vellum");
1189
- const daemonPidFile = resources?.pidFile ?? join(vellumDir, "vellum.pid");
1282
+ const daemonPidFile = getDaemonPidPath(resources);
1190
1283
  await stopProcessByPidFile(daemonPidFile, "daemon");
1191
1284
 
1192
1285
  const gatewayPidFile = join(vellumDir, "gateway.pid");
@@ -1,8 +1,5 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
- import { homedir } from "os";
3
- import { join } from "path";
4
2
 
5
- import { loadAllAssistants } from "./assistant-config.js";
6
3
  import { execOutput } from "./step-runner";
7
4
 
8
5
  export interface RemoteProcess {
@@ -74,38 +71,8 @@ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
74
71
  const results: OrphanedProcess[] = [];
75
72
  const seenPids = new Set<string>();
76
73
 
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
- ];
97
-
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
- }
105
- }
106
- }
107
-
108
- // Strategy 2: Process table scan
74
+ // Process table scan discover orphaned processes by scanning the OS
75
+ // process table rather than reading PID files from the workspace.
109
76
  try {
110
77
  const output = await execOutput("sh", [
111
78
  "-c",