@vellumai/cli 0.5.16 → 0.6.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Gateway client for authenticated requests to a hatched assistant's runtime.
3
+ *
4
+ * Encapsulates lockfile reading, guardian-token resolution, and
5
+ * authenticated fetch so callers can simply do:
6
+ *
7
+ * ```ts
8
+ * const client = new AssistantClient(); // active / latest
9
+ * const client = new AssistantClient({ assistantId: "my-bot" }); // by name
10
+ * await client.get("/healthz");
11
+ * await client.post("/messages/", { content: "hi" });
12
+ * ```
13
+ */
14
+
15
+ import {
16
+ findAssistantByName,
17
+ getActiveAssistant,
18
+ loadLatestAssistant,
19
+ } from "./assistant-config.js";
20
+ import { GATEWAY_PORT } from "./constants.js";
21
+ import { loadGuardianToken } from "./guardian-token.js";
22
+
23
+ const DEFAULT_TIMEOUT_MS = 30_000;
24
+ const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
25
+
26
+ export interface AssistantClientOpts {
27
+ assistantId?: string;
28
+ /**
29
+ * When provided alongside `orgId`, the client authenticates with a
30
+ * session token instead of a guardian token. The session token is
31
+ * sent as `Authorization: Bearer <sessionToken>` and the org id is
32
+ * sent via the `X-Vellum-Org-Id` header.
33
+ */
34
+ sessionToken?: string;
35
+ /** Required when `sessionToken` is provided. */
36
+ orgId?: string;
37
+ }
38
+
39
+ export interface RequestOpts {
40
+ timeout?: number;
41
+ signal?: AbortSignal;
42
+ headers?: Record<string, string>;
43
+ query?: Record<string, string>;
44
+ }
45
+
46
+ export class AssistantClient {
47
+ readonly runtimeUrl: string;
48
+
49
+ private readonly _assistantId: string;
50
+ private readonly token: string | undefined;
51
+ private readonly orgId: string | undefined;
52
+
53
+ /**
54
+ * Resolves an assistant entry from the lockfile and loads auth credentials.
55
+ *
56
+ * @param opts.assistantId - Explicit assistant name. When omitted, the
57
+ * active assistant is used, falling back to the most recently hatched one.
58
+ * @throws If no matching assistant is found.
59
+ */
60
+ constructor(opts?: AssistantClientOpts) {
61
+ const nameOrId = opts?.assistantId;
62
+ let entry = nameOrId ? findAssistantByName(nameOrId) : null;
63
+
64
+ if (nameOrId && !entry) {
65
+ throw new Error(`No assistant found with name '${nameOrId}'.`);
66
+ }
67
+
68
+ if (!entry) {
69
+ const active = getActiveAssistant();
70
+ if (active) {
71
+ entry = findAssistantByName(active);
72
+ }
73
+ }
74
+
75
+ if (!entry) {
76
+ entry = loadLatestAssistant();
77
+ }
78
+
79
+ if (!entry) {
80
+ throw new Error(
81
+ "No assistant found. Hatch one first with 'vellum hatch'.",
82
+ );
83
+ }
84
+
85
+ this.runtimeUrl = (
86
+ entry.localUrl ||
87
+ entry.runtimeUrl ||
88
+ FALLBACK_RUNTIME_URL
89
+ ).replace(/\/+$/, "");
90
+ this._assistantId = entry.assistantId;
91
+
92
+ if (opts?.sessionToken) {
93
+ // Platform assistant: use session token + org id header.
94
+ this.token = opts.sessionToken;
95
+ this.orgId = opts.orgId;
96
+ } else {
97
+ this.token =
98
+ loadGuardianToken(this._assistantId)?.accessToken ?? entry.bearerToken;
99
+ this.orgId = undefined;
100
+ }
101
+ }
102
+
103
+ /** GET request to the gateway. Auth headers are added automatically. */
104
+ async get(urlPath: string, opts?: RequestOpts): Promise<Response> {
105
+ return this.request("GET", urlPath, undefined, opts);
106
+ }
107
+
108
+ /**
109
+ * Subscribe to an SSE endpoint and yield parsed JSON objects from `data:` lines.
110
+ * Automatically sets `Accept: text/event-stream` and skips heartbeat comments.
111
+ */
112
+ async *stream<T = unknown>(
113
+ urlPath: string,
114
+ opts?: RequestOpts,
115
+ ): AsyncGenerator<T> {
116
+ const response = await this.get(urlPath, {
117
+ ...opts,
118
+ headers: { Accept: "text/event-stream", ...opts?.headers },
119
+ });
120
+
121
+ if (!response.ok) {
122
+ const body = await response.text().catch(() => "");
123
+ throw new Error(
124
+ `HTTP ${response.status}: ${body || response.statusText}`,
125
+ );
126
+ }
127
+
128
+ if (!response.body) {
129
+ throw new Error("No response body received.");
130
+ }
131
+
132
+ const decoder = new TextDecoder();
133
+ let buffer = "";
134
+
135
+ for await (const chunk of response.body) {
136
+ buffer += decoder.decode(chunk, { stream: true });
137
+
138
+ let boundary: number;
139
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
140
+ const frame = buffer.slice(0, boundary);
141
+ buffer = buffer.slice(boundary + 2);
142
+
143
+ if (!frame.trim() || frame.startsWith(":")) continue;
144
+
145
+ let data: string | undefined;
146
+ for (const line of frame.split("\n")) {
147
+ if (line.startsWith("data: ")) {
148
+ data = line.slice(6);
149
+ }
150
+ }
151
+
152
+ if (!data) continue;
153
+
154
+ try {
155
+ yield JSON.parse(data) as T;
156
+ } catch {
157
+ // Skip malformed JSON
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /** POST request to the gateway with a JSON body. Auth headers are added automatically. */
164
+ async post(
165
+ urlPath: string,
166
+ body: unknown,
167
+ opts?: RequestOpts,
168
+ ): Promise<Response> {
169
+ return this.request("POST", urlPath, body, opts);
170
+ }
171
+
172
+ /** PATCH request to the gateway with a JSON body. Auth headers are added automatically. */
173
+ async patch(
174
+ urlPath: string,
175
+ body: unknown,
176
+ opts?: RequestOpts,
177
+ ): Promise<Response> {
178
+ return this.request("PATCH", urlPath, body, opts);
179
+ }
180
+
181
+ private async request(
182
+ method: string,
183
+ urlPath: string,
184
+ body: unknown | undefined,
185
+ opts?: RequestOpts,
186
+ ): Promise<Response> {
187
+ const qs = opts?.query
188
+ ? `?${new URLSearchParams(opts.query).toString()}`
189
+ : "";
190
+ const url = `${this.runtimeUrl}/v1/assistants/${this._assistantId}${urlPath}${qs}`;
191
+
192
+ const headers: Record<string, string> = { ...opts?.headers };
193
+ if (this.token) {
194
+ headers["Authorization"] ??= `Bearer ${this.token}`;
195
+ }
196
+ if (this.orgId) {
197
+ headers["X-Vellum-Org-Id"] ??= this.orgId;
198
+ }
199
+ if (body !== undefined) {
200
+ headers["Content-Type"] = "application/json";
201
+ }
202
+
203
+ const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
204
+
205
+ if (opts?.signal) {
206
+ return fetch(url, {
207
+ method,
208
+ headers,
209
+ body: jsonBody,
210
+ signal: opts.signal,
211
+ });
212
+ }
213
+
214
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
215
+ const controller = new AbortController();
216
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
217
+ try {
218
+ return await fetch(url, {
219
+ method,
220
+ headers,
221
+ body: jsonBody,
222
+ signal: controller.signal,
223
+ });
224
+ } finally {
225
+ clearTimeout(timeoutId);
226
+ }
227
+ }
228
+ }
package/src/lib/aws.ts CHANGED
@@ -6,7 +6,8 @@ import { buildStartupScript, watchHatching } from "../commands/hatch";
6
6
  import type { PollResult } from "../commands/hatch";
7
7
  import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
8
8
  import type { AssistantEntry } from "./assistant-config";
9
- import { GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
9
+ import { GATEWAY_PORT } from "./constants";
10
+ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
10
11
  import type { Species } from "./constants";
11
12
  import { leaseGuardianToken } from "./guardian-token";
12
13
  import { generateInstanceName } from "./random-name";
@@ -1,5 +1,3 @@
1
- import providerEnvVarsRegistry from "../../../meta/provider-env-vars.json";
2
-
3
1
  /**
4
2
  * Canonical internal assistant ID used as the default/fallback across the CLI
5
3
  * and daemon. Mirrors `DAEMON_INTERNAL_ASSISTANT_ID` from
@@ -28,15 +26,6 @@ export const LOCKFILE_NAMES = [
28
26
  ".vellum.lockfile.json",
29
27
  ] as const;
30
28
 
31
- /**
32
- * Environment variable names for provider API keys, keyed by provider ID.
33
- * Loaded from the shared registry at `meta/provider-env-vars.json` — the
34
- * single source of truth also consumed by the assistant runtime and the
35
- * macOS client.
36
- */
37
- export const PROVIDER_ENV_VAR_NAMES: Record<string, string> =
38
- providerEnvVarsRegistry.providers;
39
-
40
29
  export const VALID_REMOTE_HOSTS = [
41
30
  "local",
42
31
  "gcp",
package/src/lib/docker.ts CHANGED
@@ -13,7 +13,8 @@ import {
13
13
  } from "./assistant-config";
14
14
  import type { AssistantEntry } from "./assistant-config";
15
15
  import { writeInitialConfig } from "./config-utils";
16
- import { DEFAULT_GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
16
+ import { DEFAULT_GATEWAY_PORT } from "./constants";
17
+ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
17
18
  import type { Species } from "./constants";
18
19
  import { leaseGuardianToken } from "./guardian-token";
19
20
  import { isVellumProcess, stopProcess } from "./process";
@@ -91,51 +92,60 @@ async function downloadAndExtract(
91
92
  }
92
93
 
93
94
  /**
94
- * Installs Docker CLI, Colima, and Lima by downloading pre-built binaries
95
- * directly into ~/.vellum/bin/. No Homebrew or sudo required.
96
- *
97
- * Falls back to Homebrew if available (e.g. admin users who prefer it).
95
+ * Installs Docker CLI (and Colima + Lima on macOS) by downloading pre-built
96
+ * binaries directly into ~/.local/bin/. No Homebrew or sudo required.
98
97
  */
99
98
  async function installDockerToolchain(): Promise<void> {
100
- // Try Homebrew first if available — it handles updates and dependencies.
101
- let hasBrew = false;
102
- try {
103
- await execOutput("brew", ["--version"]);
104
- hasBrew = true;
105
- } catch {
106
- // brew not found
107
- }
99
+ const isMac = platform() === "darwin";
100
+ const isLinux = platform() === "linux";
101
+
102
+ mkdirSync(LOCAL_BIN_DIR, { recursive: true });
103
+
104
+ const cpuArch = releaseArch();
108
105
 
109
- if (hasBrew) {
110
- console.log("🐳 Docker not found. Installing via Homebrew...");
106
+ if (isLinux) {
107
+ // On Linux, Docker runs natively — only need the Docker CLI.
108
+ console.log(
109
+ "🐳 Docker not found. Installing Docker CLI to ~/.local/bin/...",
110
+ );
111
+
112
+ const dockerArch = cpuArch === "aarch64" ? "aarch64" : "x86_64";
113
+ const dockerTarUrl = `https://download.docker.com/linux/static/stable/${dockerArch}/docker-27.5.1.tgz`;
114
+ const dockerTmpDir = join(LOCAL_BIN_DIR, ".docker-tmp");
115
+ mkdirSync(dockerTmpDir, { recursive: true });
111
116
  try {
112
- await exec("brew", ["install", "colima", "docker"]);
113
- return;
114
- } catch {
115
- console.log(
116
- " ⚠ Homebrew install failed, falling back to direct binary download...",
117
- );
117
+ await downloadAndExtract(dockerTarUrl, dockerTmpDir, "Docker CLI");
118
+ await exec("mv", [
119
+ join(dockerTmpDir, "docker", "docker"),
120
+ join(LOCAL_BIN_DIR, "docker"),
121
+ ]);
122
+ chmodSync(join(LOCAL_BIN_DIR, "docker"), 0o755);
123
+ } finally {
124
+ await exec("rm", ["-rf", dockerTmpDir]).catch(() => {});
118
125
  }
119
- }
120
-
121
- // Direct binary install — no sudo required.
122
- console.log(
123
- "🐳 Docker not found. Installing Docker, Colima, and Lima to ~/.local/bin/...",
124
- );
125
126
 
126
- mkdirSync(LOCAL_BIN_DIR, { recursive: true });
127
+ if (!existsSync(join(LOCAL_BIN_DIR, "docker"))) {
128
+ throw new Error(
129
+ "docker binary not found after installation. Please install Docker manually: https://docs.docker.com/engine/install/",
130
+ );
131
+ }
127
132
 
128
- const cpuArch = releaseArch();
129
- const isMac = platform() === "darwin";
133
+ console.log(" āœ… Docker CLI installed to ~/.local/bin/");
134
+ return;
135
+ }
130
136
 
131
137
  if (!isMac) {
132
138
  throw new Error(
133
- "Automatic Docker installation is only supported on macOS. " +
139
+ "Automatic Docker installation is only supported on macOS and Linux. " +
134
140
  "Please install Docker manually: https://docs.docker.com/engine/install/",
135
141
  );
136
142
  }
137
143
 
138
- // --- Docker CLI ---
144
+ console.log(
145
+ "🐳 Docker not found. Installing Docker, Colima, and Lima to ~/.local/bin/...",
146
+ );
147
+
148
+ // --- Docker CLI (macOS) ---
139
149
  // Docker publishes static binaries at download.docker.com.
140
150
  const dockerArch = cpuArch === "aarch64" ? "aarch64" : "x86_64";
141
151
  const dockerTarUrl = `https://download.docker.com/mac/static/stable/${dockerArch}/docker-27.5.1.tgz`;
@@ -222,13 +232,17 @@ async function ensureDockerInstalled(): Promise<void> {
222
232
  // Always add ~/.local/bin to PATH so previously installed binaries are found.
223
233
  ensureLocalBinOnPath();
224
234
 
225
- // Check that docker, colima, and limactl are all available. If any is
226
- // missing (e.g. partial install from a previous failure), re-run install.
235
+ const isLinux = platform() === "linux";
236
+
237
+ // On Linux, Docker runs natively — only need the docker CLI + daemon.
238
+ // On macOS, we also need Colima and Lima to provide a Linux VM.
227
239
  const toolchainComplete = await (async () => {
228
240
  try {
229
241
  await execOutput("docker", ["--version"]);
230
- await execOutput("colima", ["version"]);
231
- await execOutput("limactl", ["--version"]);
242
+ if (!isLinux) {
243
+ await execOutput("colima", ["version"]);
244
+ await execOutput("limactl", ["--version"]);
245
+ }
232
246
  return true;
233
247
  } catch {
234
248
  return false;
@@ -250,10 +264,19 @@ async function ensureDockerInstalled(): Promise<void> {
250
264
  }
251
265
  }
252
266
 
253
- // Verify the Docker daemon is reachable; start Colima if it isn't.
267
+ // Verify the Docker daemon is reachable.
254
268
  try {
255
269
  await exec("docker", ["info"]);
256
270
  } catch {
271
+ // On Linux, the daemon must already be running (systemd, etc.).
272
+ if (isLinux) {
273
+ throw new Error(
274
+ "Docker daemon is not running. Please start it with 'sudo systemctl start docker' " +
275
+ "or ensure the Docker service is enabled.",
276
+ );
277
+ }
278
+
279
+ // On macOS, try starting Colima.
257
280
  let hasColima = false;
258
281
  try {
259
282
  await execOutput("colima", ["version"]);
@@ -393,6 +416,15 @@ function walkUpForRepoRoot(startDir: string): string | undefined {
393
416
  return undefined;
394
417
  }
395
418
 
419
+ /**
420
+ * Returns `true` when the given root looks like a full source checkout
421
+ * (has assistant source code), as opposed to a packaged `.app` bundle
422
+ * that only contains the Dockerfiles.
423
+ */
424
+ function hasFullSourceTree(root: string): boolean {
425
+ return existsSync(join(root, "assistant", "package.json"));
426
+ }
427
+
396
428
  /**
397
429
  * Locate the repository root by walking up from `cli/src/lib/` until we
398
430
  * find a directory containing the expected Dockerfiles.
@@ -413,6 +445,20 @@ function findRepoRoot(): string {
413
445
  return execRoot;
414
446
  }
415
447
 
448
+ // Check the app bundle's Resources directory. Debug DMG builds bundle
449
+ // Dockerfiles at Contents/Resources/dockerfiles/{assistant,gateway,...}/Dockerfile.
450
+ // The CLI binary lives at Contents/MacOS/vellum-cli, so Resources is at
451
+ // ../Resources relative to the binary.
452
+ const bundledRoot = join(
453
+ dirname(process.execPath),
454
+ "..",
455
+ "Resources",
456
+ "dockerfiles",
457
+ );
458
+ if (existsSync(join(bundledRoot, "assistant", "Dockerfile"))) {
459
+ return bundledRoot;
460
+ }
461
+
416
462
  // Walk up from cwd as a final fallback
417
463
  const cwdRoot = walkUpForRepoRoot(process.cwd());
418
464
  if (cwdRoot) {
@@ -479,8 +525,9 @@ async function buildAllImages(
479
525
 
480
526
  /**
481
527
  * Returns a function that builds the `docker run` arguments for a given
482
- * service. Each container joins a shared Docker bridge network so they
483
- * can be restarted independently.
528
+ * service. All three containers share a network namespace via
529
+ * `--network=container:` so inter-service traffic is over localhost,
530
+ * matching the platform's Kubernetes pod topology.
484
531
  */
485
532
  export function serviceDockerRunArgs(opts: {
486
533
  signingKey?: string;
@@ -511,12 +558,14 @@ export function serviceDockerRunArgs(opts: {
511
558
  "--name",
512
559
  res.assistantContainer,
513
560
  `--network=${res.network}`,
561
+ "-p",
562
+ `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
514
563
  "-v",
515
564
  `${res.workspaceVolume}:/workspace`,
516
565
  "-v",
517
566
  `${res.socketVolume}:/run/ces-bootstrap`,
518
567
  "-e",
519
- "IS_CONTAINERIZED=false",
568
+ "IS_CONTAINERIZED=true",
520
569
  "-e",
521
570
  `VELLUM_ASSISTANT_NAME=${instanceName}`,
522
571
  "-e",
@@ -526,9 +575,9 @@ export function serviceDockerRunArgs(opts: {
526
575
  "-e",
527
576
  "VELLUM_WORKSPACE_DIR=/workspace",
528
577
  "-e",
529
- `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
578
+ "CES_CREDENTIAL_URL=http://localhost:8090",
530
579
  "-e",
531
- `GATEWAY_INTERNAL_URL=http://${res.gatewayContainer}:${GATEWAY_INTERNAL_PORT}`,
580
+ `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
532
581
  ];
533
582
  if (defaultWorkspaceConfigPath) {
534
583
  const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
@@ -567,9 +616,7 @@ export function serviceDockerRunArgs(opts: {
567
616
  "-d",
568
617
  "--name",
569
618
  res.gatewayContainer,
570
- `--network=${res.network}`,
571
- "-p",
572
- `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
619
+ `--network=container:${res.assistantContainer}`,
573
620
  "-v",
574
621
  `${res.workspaceVolume}:/workspace`,
575
622
  "-v",
@@ -581,13 +628,13 @@ export function serviceDockerRunArgs(opts: {
581
628
  "-e",
582
629
  `GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
583
630
  "-e",
584
- `ASSISTANT_HOST=${res.assistantContainer}`,
631
+ "ASSISTANT_HOST=localhost",
585
632
  "-e",
586
633
  `RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
587
634
  "-e",
588
635
  "RUNTIME_PROXY_ENABLED=true",
589
636
  "-e",
590
- `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
637
+ "CES_CREDENTIAL_URL=http://localhost:8090",
591
638
  ...(cesServiceToken
592
639
  ? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
593
640
  : []),
@@ -605,7 +652,7 @@ export function serviceDockerRunArgs(opts: {
605
652
  "-d",
606
653
  "--name",
607
654
  res.cesContainer,
608
- `--network=${res.network}`,
655
+ `--network=container:${res.assistantContainer}`,
609
656
  "-v",
610
657
  `${res.socketVolume}:/run/ces-bootstrap`,
611
658
  "-v",
@@ -689,11 +736,13 @@ export async function sleepContainers(
689
736
  }
690
737
  }
691
738
 
692
- /** Start existing stopped containers, starting Colima first if it isn't running. */
739
+ /** Start existing stopped containers, starting Colima first if it isn't running (macOS only). */
693
740
  export async function wakeContainers(
694
741
  res: ReturnType<typeof dockerResourceNames>,
695
742
  ): Promise<void> {
696
- await ensureColimaRunning();
743
+ if (platform() !== "linux") {
744
+ await ensureColimaRunning();
745
+ }
697
746
  for (const container of [
698
747
  res.assistantContainer,
699
748
  res.gatewayContainer,
@@ -803,6 +852,9 @@ function affectedServices(
803
852
  * images and restart their containers.
804
853
  */
805
854
  function startFileWatcher(opts: {
855
+ signingKey?: string;
856
+ bootstrapSecret?: string;
857
+ cesServiceToken?: string;
806
858
  gatewayPort: number;
807
859
  imageTags: Record<ServiceName, string>;
808
860
  instanceName: string;
@@ -824,6 +876,9 @@ function startFileWatcher(opts: {
824
876
 
825
877
  const configs = serviceImageConfigs(repoRoot, imageTags);
826
878
  const runArgs = serviceDockerRunArgs({
879
+ signingKey: opts.signingKey,
880
+ bootstrapSecret: opts.bootstrapSecret,
881
+ cesServiceToken: opts.cesServiceToken,
827
882
  gatewayPort,
828
883
  imageTags,
829
884
  instanceName,
@@ -842,6 +897,15 @@ function startFileWatcher(opts: {
842
897
  const services = pendingServices;
843
898
  pendingServices = new Set();
844
899
 
900
+ // Gateway and CES share the assistant's network namespace. If the
901
+ // assistant container is removed and recreated, the shared namespace
902
+ // is destroyed and the other two lose connectivity. Cascade the
903
+ // restart to all three services in that case.
904
+ if (services.has("assistant")) {
905
+ services.add("gateway");
906
+ services.add("credential-executor");
907
+ }
908
+
845
909
  const serviceNames = [...services].join(", ");
846
910
  console.log(`\nšŸ”„ Changes detected — rebuilding: ${serviceNames}`);
847
911
 
@@ -854,7 +918,10 @@ function startFileWatcher(opts: {
854
918
  }),
855
919
  );
856
920
 
857
- for (const service of services) {
921
+ // Restart in dependency order (assistant first) so the network
922
+ // namespace owner is up before dependents try to attach.
923
+ for (const service of SERVICE_START_ORDER) {
924
+ if (!services.has(service)) continue;
858
925
  const container = containerForService[service];
859
926
  console.log(`šŸ”„ Restarting ${container}...`);
860
927
  await removeContainer(container);
@@ -949,8 +1016,23 @@ export async function hatchDocker(
949
1016
  let repoRoot: string | undefined;
950
1017
 
951
1018
  if (watch) {
952
- emitProgress(2, 6, "Building images...");
953
1019
  repoRoot = findRepoRoot();
1020
+
1021
+ // When running from a packaged .app bundle, the Dockerfiles are
1022
+ // present (so findRepoRoot succeeds) but the full source tree is
1023
+ // not — we can't build images locally. Fall back to pulling
1024
+ // pre-built images instead.
1025
+ if (!hasFullSourceTree(repoRoot)) {
1026
+ log(
1027
+ "āš ļø Dockerfiles found but no source tree — falling back to image pull",
1028
+ );
1029
+ watch = false;
1030
+ repoRoot = undefined;
1031
+ }
1032
+ }
1033
+
1034
+ if (watch && repoRoot) {
1035
+ emitProgress(2, 6, "Building images...");
954
1036
  const localTag = `local-${instanceName}`;
955
1037
  imageTags.assistant = `vellum-assistant:${localTag}`;
956
1038
  imageTags.gateway = `vellum-gateway:${localTag}`;
@@ -969,20 +1051,41 @@ export async function hatchDocker(
969
1051
 
970
1052
  await buildAllImages(repoRoot, imageTags, log);
971
1053
  log("āœ… Docker images built");
972
- } else {
1054
+ }
1055
+
1056
+ if (!watch || !repoRoot) {
973
1057
  emitProgress(2, 6, "Pulling images...");
974
- const version = cliPkg.version;
975
- const versionTag = version ? `v${version}` : "latest";
976
- log("šŸ” Resolving image references...");
977
- const resolved = await resolveImageRefs(versionTag, log);
978
- imageTags.assistant = resolved.imageTags.assistant;
979
- imageTags.gateway = resolved.imageTags.gateway;
980
- imageTags["credential-executor"] =
981
- resolved.imageTags["credential-executor"];
1058
+
1059
+ // Allow explicit image overrides via environment variables.
1060
+ // When all three are set, skip version-based resolution entirely.
1061
+ const envAssistant = process.env.VELLUM_ASSISTANT_IMAGE;
1062
+ const envGateway = process.env.VELLUM_GATEWAY_IMAGE;
1063
+ const envCredentialExecutor =
1064
+ process.env.VELLUM_CREDENTIAL_EXECUTOR_IMAGE;
1065
+
1066
+ let imageSource: string;
1067
+
1068
+ if (envAssistant && envGateway && envCredentialExecutor) {
1069
+ imageTags.assistant = envAssistant;
1070
+ imageTags.gateway = envGateway;
1071
+ imageTags["credential-executor"] = envCredentialExecutor;
1072
+ imageSource = "env override";
1073
+ log("Using image overrides from environment variables");
1074
+ } else {
1075
+ const version = cliPkg.version;
1076
+ const versionTag = version ? `v${version}` : "latest";
1077
+ log("šŸ” Resolving image references...");
1078
+ const resolved = await resolveImageRefs(versionTag, log);
1079
+ imageTags.assistant = resolved.imageTags.assistant;
1080
+ imageTags.gateway = resolved.imageTags.gateway;
1081
+ imageTags["credential-executor"] =
1082
+ resolved.imageTags["credential-executor"];
1083
+ imageSource = resolved.source;
1084
+ }
982
1085
 
983
1086
  log(`🄚 Hatching Docker assistant: ${instanceName}`);
984
1087
  log(` Species: ${species}`);
985
- log(` Images (${resolved.source}):`);
1088
+ log(` Images (${imageSource}):`);
986
1089
  log(` assistant: ${imageTags.assistant}`);
987
1090
  log(` gateway: ${imageTags.gateway}`);
988
1091
  log(` credential-executor: ${imageTags["credential-executor"]}`);
@@ -1092,6 +1195,9 @@ export async function hatchDocker(
1092
1195
  saveAssistantEntry({ ...dockerEntry, watcherPid: process.pid });
1093
1196
 
1094
1197
  const stopWatcher = startFileWatcher({
1198
+ signingKey,
1199
+ bootstrapSecret,
1200
+ cesServiceToken,
1095
1201
  gatewayPort,
1096
1202
  imageTags,
1097
1203
  instanceName,
package/src/lib/gcp.ts CHANGED
@@ -4,11 +4,8 @@ import { join } from "path";
4
4
 
5
5
  import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
6
6
  import type { AssistantEntry } from "./assistant-config";
7
- import {
8
- FIREWALL_TAG,
9
- GATEWAY_PORT,
10
- PROVIDER_ENV_VAR_NAMES,
11
- } from "./constants";
7
+ import { FIREWALL_TAG, GATEWAY_PORT } from "./constants";
8
+ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
12
9
  import type { Species } from "./constants";
13
10
  import { leaseGuardianToken } from "./guardian-token";
14
11
  import { getPlatformUrl } from "./platform-client";