@vellumai/cli 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,14 +10,9 @@ import {
10
10
  import { homedir } from "os";
11
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
- } from "./constants.js";
20
- import {
15
+ getDefaultPorts,
21
16
  getLockfilePath,
22
17
  getLockfilePaths,
23
18
  getMultiInstanceDir,
@@ -187,6 +182,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
187
182
  }
188
183
 
189
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,7 +202,7 @@ 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
207
  getMultiInstanceDir(env),
212
208
  typeof raw.assistantId === "string"
@@ -215,10 +211,10 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
215
211
  );
216
212
  raw.resources = {
217
213
  instanceDir,
218
- daemonPort: DEFAULT_DAEMON_PORT,
214
+ daemonPort: defaultPorts.daemon,
219
215
  gatewayPort,
220
- qdrantPort: DEFAULT_QDRANT_PORT,
221
- cesPort: DEFAULT_CES_PORT,
216
+ qdrantPort: defaultPorts.qdrant,
217
+ cesPort: defaultPorts.ces,
222
218
  pidFile: join(instanceDir, ".vellum", "vellum.pid"),
223
219
  };
224
220
  mutated = true;
@@ -235,20 +231,20 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
235
231
  mutated = true;
236
232
  }
237
233
  if (typeof res.daemonPort !== "number") {
238
- res.daemonPort = DEFAULT_DAEMON_PORT;
234
+ res.daemonPort = defaultPorts.daemon;
239
235
  mutated = true;
240
236
  }
241
237
  if (typeof res.gatewayPort !== "number") {
242
238
  res.gatewayPort =
243
- parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
239
+ parsePortFromUrl(raw.runtimeUrl) ?? defaultPorts.gateway;
244
240
  mutated = true;
245
241
  }
246
242
  if (typeof res.qdrantPort !== "number") {
247
- res.qdrantPort = DEFAULT_QDRANT_PORT;
243
+ res.qdrantPort = defaultPorts.qdrant;
248
244
  mutated = true;
249
245
  }
250
246
  if (typeof res.cesPort !== "number") {
251
- res.cesPort = DEFAULT_CES_PORT;
247
+ res.cesPort = defaultPorts.ces;
252
248
  mutated = true;
253
249
  }
254
250
  if (typeof res.pidFile !== "string") {
@@ -378,6 +374,22 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
378
374
  process.exit(1);
379
375
  }
380
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
+
381
393
  export function saveAssistantEntry(entry: AssistantEntry): void {
382
394
  const entries = readAssistants().filter(
383
395
  (e) => e.assistantId !== entry.assistantId,
@@ -435,20 +447,21 @@ export async function allocateLocalResources(
435
447
  );
436
448
  }
437
449
 
438
- const daemonPort = await findAvailablePort(
439
- DEFAULT_DAEMON_PORT,
440
- reservedPorts,
441
- );
442
- 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, [
443
456
  ...reservedPorts,
444
457
  daemonPort,
445
458
  ]);
446
- const qdrantPort = await findAvailablePort(DEFAULT_QDRANT_PORT, [
459
+ const qdrantPort = await findAvailablePort(basePorts.qdrant, [
447
460
  ...reservedPorts,
448
461
  daemonPort,
449
462
  gatewayPort,
450
463
  ]);
451
- const cesPort = await findAvailablePort(DEFAULT_CES_PORT, [
464
+ const cesPort = await findAvailablePort(basePorts.ces, [
452
465
  ...reservedPorts,
453
466
  daemonPort,
454
467
  gatewayPort,
@@ -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
  */
package/src/lib/docker.ts CHANGED
@@ -6,6 +6,13 @@ 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
+
9
16
  import {
10
17
  findAssistantByName,
11
18
  saveAssistantEntry,
@@ -13,9 +20,10 @@ import {
13
20
  } from "./assistant-config";
14
21
  import type { AssistantEntry } from "./assistant-config";
15
22
  import { writeInitialConfig } from "./config-utils";
16
- import { DEFAULT_GATEWAY_PORT } from "./constants";
17
23
  import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
18
24
  import type { Species } from "./constants";
25
+ import { getDefaultPorts } from "./environments/paths.js";
26
+ import { getCurrentEnvironment } from "./environments/resolve.js";
19
27
  import { leaseGuardianToken } from "./guardian-token";
20
28
  import { isVellumProcess, stopProcess } from "./process";
21
29
  import { generateInstanceName } from "./random-name";
@@ -45,6 +53,64 @@ export const GATEWAY_INTERNAL_PORT = 7830;
45
53
  /** Max time to wait for the assistant container to emit the readiness sentinel. */
46
54
  export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
47
55
 
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";
82
+
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";
88
+
89
+ /**
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.
93
+ */
94
+ export function resolveMeetAvatarDevicePath(
95
+ 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];
109
+ return override && override.length > 0
110
+ ? override
111
+ : DEFAULT_MEET_AVATAR_DEVICE_PATH;
112
+ }
113
+
48
114
  /** Default memory (GiB) allocated to the Colima VM. */
49
115
  const COLIMA_DEFAULT_MEMORY_GIB = 8;
50
116
 
@@ -515,8 +581,8 @@ function serviceImageConfigs(
515
581
  tag: imageTags["credential-executor"],
516
582
  },
517
583
  gateway: {
518
- context: join(repoRoot, "gateway"),
519
- dockerfile: "Dockerfile",
584
+ context: repoRoot,
585
+ dockerfile: "gateway/Dockerfile",
520
586
  tag: imageTags.gateway,
521
587
  },
522
588
  };
@@ -660,6 +726,24 @@ export function serviceDockerRunArgs(opts: {
660
726
  args.push("-e", `${key}=${value}`);
661
727
  }
662
728
  }
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) {
738
+ args.push(
739
+ "--device",
740
+ `${avatarDevice}:${avatarDevice}`,
741
+ "-e",
742
+ `${MEET_AVATAR_ENV_VAR}=1`,
743
+ "-e",
744
+ `${MEET_AVATAR_DEVICE_ENV_VAR}=${avatarDevice}`,
745
+ );
746
+ }
663
747
  args.push(imageTags.assistant);
664
748
  return args;
665
749
  },
@@ -1074,7 +1158,7 @@ export async function hatchDocker(
1074
1158
  await ensureDockerInstalled();
1075
1159
 
1076
1160
  const instanceName = generateInstanceName(species, name);
1077
- const gatewayPort = DEFAULT_GATEWAY_PORT;
1161
+ const gatewayPort = getDefaultPorts(getCurrentEnvironment()).gateway;
1078
1162
 
1079
1163
  const imageTags: Record<ServiceName, string> = {
1080
1164
  assistant: "",
@@ -200,18 +200,12 @@ describe("path helpers", () => {
200
200
  expect(ports.tcp).toBe(8765);
201
201
  });
202
202
 
203
- test("returns identical defaults for dev (Phase 5 deferred)", () => {
203
+ test("returns base defaults for a bare env with no portsOverride", () => {
204
+ // Bare env literal (no portsOverride) falls through to DEFAULT_PORTS.
205
+ // Real non-prod seeds populate portsOverride — see seeds.test cases.
204
206
  expect(getDefaultPorts(dev)).toEqual(getDefaultPorts(prod));
205
207
  });
206
208
 
207
- test("returns identical defaults for staging", () => {
208
- const staging: EnvironmentDefinition = {
209
- name: "staging",
210
- platformUrl: "https://staging-platform.vellum.ai",
211
- };
212
- expect(getDefaultPorts(staging)).toEqual(getDefaultPorts(prod));
213
- });
214
-
215
209
  test("merges env.portsOverride on top of defaults", () => {
216
210
  const env: EnvironmentDefinition = {
217
211
  ...dev,
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { getDefaultPorts } from "../paths.js";
4
+ import { SEEDS } from "../seeds.js";
5
+
6
+ describe("SEEDS port blocks", () => {
7
+ test("production uses the legacy (pre-MVP) port layout", () => {
8
+ const ports = getDefaultPorts(SEEDS.production!);
9
+ expect(ports).toEqual({
10
+ daemon: 7821,
11
+ gateway: 7830,
12
+ qdrant: 6333,
13
+ ces: 8090,
14
+ outboundProxy: 8080,
15
+ tcp: 8765,
16
+ });
17
+ });
18
+
19
+ test.each([
20
+ ["staging", 17000],
21
+ ["dev", 18000],
22
+ ["test", 19000],
23
+ ["local", 20000],
24
+ ] as const)("%s block starts at %i with 100-apart services", (name, base) => {
25
+ const ports = getDefaultPorts(SEEDS[name]!);
26
+ expect(ports).toEqual({
27
+ daemon: base,
28
+ gateway: base + 100,
29
+ qdrant: base + 200,
30
+ ces: base + 300,
31
+ outboundProxy: base + 400,
32
+ tcp: base + 500,
33
+ });
34
+ });
35
+
36
+ test("non-prod blocks are disjoint across environments", () => {
37
+ // 100 instances per service is the scan headroom in findAvailablePort,
38
+ // so a block "occupies" base…base+599 from daemon through tcp. Verify no
39
+ // two blocks overlap for any service.
40
+ const blocks = (["staging", "dev", "test", "local"] as const).map(
41
+ (name) => ({
42
+ name,
43
+ ports: getDefaultPorts(SEEDS[name]!),
44
+ }),
45
+ );
46
+ const allPorts = new Set<number>();
47
+ for (const { name, ports } of blocks) {
48
+ for (const port of Object.values(ports)) {
49
+ // Within each block, each service has 100 slots (base…base+99).
50
+ for (let offset = 0; offset < 100; offset++) {
51
+ const p = port + offset;
52
+ if (allPorts.has(p)) {
53
+ throw new Error(
54
+ `port ${p} (in ${name}'s block) overlaps another env's block`,
55
+ );
56
+ }
57
+ allPorts.add(p);
58
+ }
59
+ }
60
+ }
61
+ });
62
+
63
+ test("non-prod blocks sit below Linux's default ephemeral range (32768)", () => {
64
+ for (const name of ["staging", "dev", "test", "local"] as const) {
65
+ const ports = getDefaultPorts(SEEDS[name]!);
66
+ for (const port of Object.values(ports)) {
67
+ // Max port we'll ever scan to is base+99 for daemon/gateway/etc.
68
+ expect(port + 99).toBeLessThan(32768);
69
+ }
70
+ }
71
+ });
72
+ });
@@ -86,11 +86,10 @@ export function getMultiInstanceDir(env: EnvironmentDefinition): string {
86
86
  }
87
87
 
88
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.
89
+ * Default port set for an environment.
90
+ * Seed entries for non-prod environments come with separate port ranges
91
+ * to avoid collisions in multi-env / multi-instance setups.
92
+ * Longer term, consider allocating ports dynamically at hatch/wake time.
94
93
  */
95
94
  export function getDefaultPorts(env: EnvironmentDefinition): PortMap {
96
95
  return {
@@ -1,4 +1,28 @@
1
- import type { EnvironmentDefinition } from "./types.js";
1
+ import type { EnvironmentDefinition, PortMap } from "./types.js";
2
+
3
+ /**
4
+ * Non-prod port blocks. Each environment gets a 1000-port window in the
5
+ * 17000–21000 band. Within a block, services are spaced 100 apart so up to
6
+ * 100 assistants can coexist without the scan (`findAvailablePort`) running
7
+ * one service's range into the next. Band chosen to sit below Linux's
8
+ * default ephemeral start (32768) and macOS's (49152), and away from the
9
+ * 3000/5000/8000/9000 dev-tool swamp. Production keeps its legacy,
10
+ * non-contiguous port set (7821/7830/6333/8090/8080/8765): cross-env
11
+ * collision is the only problem this change targets, prod is unaffected
12
+ * because only one env's assistants compete on a given machine, and
13
+ * churning it would leave existing hatches on 7821 while new ones
14
+ * allocated elsewhere.
15
+ */
16
+ function portBlock(base: number): PortMap {
17
+ return {
18
+ daemon: base,
19
+ gateway: base + 100,
20
+ qdrant: base + 200,
21
+ ces: base + 300,
22
+ outboundProxy: base + 400,
23
+ tcp: base + 500,
24
+ };
25
+ }
2
26
 
3
27
  /**
4
28
  * Built-in environment definitions. Mirrors Swift's
@@ -25,16 +49,19 @@ export const SEEDS: Record<string, EnvironmentDefinition> = {
25
49
  staging: {
26
50
  name: "staging",
27
51
  platformUrl: "https://staging-platform.vellum.ai",
52
+ portsOverride: portBlock(17000),
28
53
  },
29
54
  test: {
30
55
  name: "test",
31
56
  // Non-functional URL — used only by unit tests for URL resolution, never
32
57
  // hit in production.
33
58
  platformUrl: "https://test-platform.vellum.ai",
59
+ portsOverride: portBlock(19000),
34
60
  },
35
61
  dev: {
36
62
  name: "dev",
37
63
  platformUrl: "https://dev-platform.vellum.ai",
64
+ portsOverride: portBlock(18000),
38
65
  },
39
66
  local: {
40
67
  name: "local",
@@ -42,5 +69,6 @@ export const SEEDS: Record<string, EnvironmentDefinition> = {
42
69
  // assistantPlatformUrl: "http://host.docker.internal:8000",
43
70
  // ^ uncomment this once dockerized hatch path is live.
44
71
  // The assistant runs in a different network namespace than the host.
72
+ portsOverride: portBlock(20000),
45
73
  },
46
74
  };
@@ -0,0 +1,122 @@
1
+ import { createConnection } from "net";
2
+ import { existsSync } from "fs";
3
+
4
+ import type { AssistantEntry } from "./assistant-config";
5
+
6
+ /**
7
+ * Execute a command inside an Apple Container assistant via the management
8
+ * socket. Non-interactive: sends the command, streams stdout/stderr to the
9
+ * terminal, and exits with the appropriate code.
10
+ */
11
+ export async function execAppleContainer(
12
+ entry: AssistantEntry,
13
+ command: string[],
14
+ service: string,
15
+ ): Promise<void> {
16
+ const mgmtSocket = entry.mgmtSocket as string | undefined;
17
+ if (!mgmtSocket) {
18
+ console.error(
19
+ `No management socket found for '${entry.assistantId}'.\n` +
20
+ "The assistant may not be running.",
21
+ );
22
+ process.exit(1);
23
+ }
24
+
25
+ if (!existsSync(mgmtSocket)) {
26
+ console.error(
27
+ `Management socket not found at ${mgmtSocket}.\n` +
28
+ "The assistant may have been stopped.",
29
+ );
30
+ process.exit(1);
31
+ }
32
+
33
+ const handshake =
34
+ JSON.stringify({
35
+ command,
36
+ service,
37
+ cols: process.stdout.columns || 120,
38
+ rows: process.stdout.rows || 40,
39
+ }) + "\n";
40
+
41
+ return new Promise<void>((resolve, reject) => {
42
+ const socket = createConnection({ path: mgmtSocket }, () => {
43
+ socket.write(handshake);
44
+ });
45
+
46
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
47
+ let handshakeComplete = false;
48
+ const handshakeChunks: Buffer[] = [];
49
+ let handshakeLen = 0;
50
+
51
+ socket.setTimeout(HANDSHAKE_TIMEOUT_MS);
52
+ socket.on("timeout", () => {
53
+ if (!handshakeComplete) {
54
+ console.error("Timed out waiting for response from management socket.");
55
+ socket.destroy();
56
+ process.exit(1);
57
+ }
58
+ });
59
+
60
+ socket.on("data", (data: Buffer) => {
61
+ if (!handshakeComplete) {
62
+ handshakeChunks.push(data);
63
+ handshakeLen += data.length;
64
+ const accumulated = Buffer.concat(handshakeChunks, handshakeLen);
65
+ const nlIndex = accumulated.indexOf(0x0a);
66
+ if (nlIndex === -1) return;
67
+
68
+ const responseLine = accumulated.slice(0, nlIndex).toString("utf-8");
69
+ const remainder = accumulated.slice(nlIndex + 1);
70
+ handshakeComplete = true;
71
+ socket.setTimeout(0);
72
+
73
+ let response: { status: string; message?: string };
74
+ try {
75
+ response = JSON.parse(responseLine) as {
76
+ status: string;
77
+ message?: string;
78
+ };
79
+ } catch {
80
+ console.error("Invalid response from management socket.");
81
+ socket.destroy();
82
+ process.exit(1);
83
+ return;
84
+ }
85
+
86
+ if (response.status !== "ok") {
87
+ console.error(`Exec failed: ${response.message || "unknown error"}`);
88
+ socket.destroy();
89
+ process.exit(1);
90
+ return;
91
+ }
92
+
93
+ // Write any bytes that arrived after the handshake newline.
94
+ if (remainder.length > 0) {
95
+ process.stdout.write(remainder);
96
+ }
97
+ return;
98
+ }
99
+
100
+ // Stream command output to stdout.
101
+ process.stdout.write(data);
102
+ });
103
+
104
+ socket.on("end", () => {
105
+ if (handshakeComplete) {
106
+ resolve();
107
+ } else {
108
+ reject(new Error("Connection closed before handshake completed."));
109
+ }
110
+ });
111
+
112
+ socket.on("error", (err) => {
113
+ reject(new Error(`Management socket error: ${err.message}`));
114
+ });
115
+
116
+ socket.on("close", () => {
117
+ if (handshakeComplete) {
118
+ resolve();
119
+ }
120
+ });
121
+ });
122
+ }
@@ -5,6 +5,7 @@ import {
5
5
  existsSync,
6
6
  mkdirSync,
7
7
  readFileSync,
8
+ statSync,
8
9
  writeFileSync,
9
10
  } from "fs";
10
11
  import { platform } from "os";
@@ -12,6 +13,7 @@ import { dirname, join } from "path";
12
13
 
13
14
  import { getConfigDir } from "./environments/paths.js";
14
15
  import { getCurrentEnvironment } from "./environments/resolve.js";
16
+ import { SEEDS } from "./environments/seeds.js";
15
17
 
16
18
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
17
19
 
@@ -200,3 +202,64 @@ export async function leaseGuardianToken(
200
202
  saveGuardianToken(assistantId, tokenData);
201
203
  return tokenData;
202
204
  }
205
+
206
+ /**
207
+ * Copy a guardian token from a sibling environment's config directory into
208
+ * the current environment's dir when the current one is missing it.
209
+ *
210
+ * The CLI's per-environment config layout (`~/.config/vellum{-env}/`) scopes
211
+ * the lockfile and the guardian token by VELLUM_ENVIRONMENT. Lockfiles are
212
+ * cross-written at hatch time, but a guardian token is only written under
213
+ * the env the assistant was hatched in. If the user later wakes the same
214
+ * assistant under a different env (e.g. a freshly built desktop app ships
215
+ * with VELLUM_ENVIRONMENT=local while the original hatch was under dev),
216
+ * the app cannot locate a bearer token and falls into a 401 → auth-rate-
217
+ * limit → 429 cascade against the local gateway.
218
+ *
219
+ * Returns true if a token was seeded, false if a token was already present
220
+ * or no sibling env had one to copy.
221
+ */
222
+ export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
223
+ if (loadGuardianToken(assistantId) !== null) return false;
224
+
225
+ const currentEnvName = getCurrentEnvironment().name;
226
+ const destPath = getGuardianTokenPath(assistantId);
227
+
228
+ const candidates: { path: string; mtimeMs: number }[] = [];
229
+ for (const env of Object.values(SEEDS)) {
230
+ if (env.name === currentEnvName) continue;
231
+ const sibling = join(
232
+ getConfigDir(env),
233
+ "assistants",
234
+ assistantId,
235
+ "guardian-token.json",
236
+ );
237
+ try {
238
+ const stat = statSync(sibling);
239
+ candidates.push({ path: sibling, mtimeMs: stat.mtimeMs });
240
+ } catch {
241
+ continue;
242
+ }
243
+ }
244
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
245
+
246
+ const now = Date.now();
247
+ for (const { path: sibling } of candidates) {
248
+ try {
249
+ const raw = readFileSync(sibling);
250
+ const parsed = JSON.parse(raw.toString("utf-8")) as GuardianTokenData;
251
+ const refreshExpiry = Date.parse(parsed.refreshTokenExpiresAt);
252
+ if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
253
+ const dir = dirname(destPath);
254
+ if (!existsSync(dir)) {
255
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
256
+ }
257
+ writeFileSync(destPath, raw, { mode: 0o600 });
258
+ chmodSync(destPath, 0o600);
259
+ return true;
260
+ } catch {
261
+ continue;
262
+ }
263
+ }
264
+ return false;
265
+ }
@@ -305,10 +305,26 @@ export async function hatchLocal(
305
305
  // IP which the daemon rejects as non-loopback.
306
306
  emitProgress(6, 7, "Securing connection...");
307
307
  const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
308
- try {
309
- await leaseGuardianToken(loopbackUrl, instanceName);
310
- } catch (err) {
311
- console.error(`⚠️ Guardian token lease failed: ${err}`);
308
+ const maxLeaseAttempts = 3;
309
+ for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
310
+ try {
311
+ await leaseGuardianToken(loopbackUrl, instanceName);
312
+ break;
313
+ } catch (err) {
314
+ if (attempt < maxLeaseAttempts) {
315
+ const delayMs = 2000 * 2 ** (attempt - 1);
316
+ console.error(
317
+ `⚠️ Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`,
318
+ );
319
+ await new Promise((r) => setTimeout(r, delayMs));
320
+ } else {
321
+ console.error(
322
+ `⚠️ Guardian token lease failed after ${maxLeaseAttempts} attempts: ${err}\n` +
323
+ ` The assistant is running but guardian-token.json was not written.\n` +
324
+ ` If the desktop app loses its stored credentials, re-hatch to recover.`,
325
+ );
326
+ }
327
+ }
312
328
  }
313
329
 
314
330
  // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.