@vellumai/cli 0.5.5 → 0.5.7

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.
package/src/lib/docker.ts CHANGED
@@ -12,11 +12,13 @@ import {
12
12
  setActiveAssistant,
13
13
  } from "./assistant-config";
14
14
  import type { AssistantEntry } from "./assistant-config";
15
+ import { writeInitialConfig } from "./config-utils";
15
16
  import { DEFAULT_GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
16
17
  import type { Species } from "./constants";
17
- import { leaseGuardianToken } from "./guardian-token";
18
+ import { leaseGuardianToken, saveBootstrapSecret } from "./guardian-token";
18
19
  import { isVellumProcess, stopProcess } from "./process";
19
20
  import { generateInstanceName } from "./random-name";
21
+ import { resolveImageRefs } from "./platform-releases.js";
20
22
  import { exec, execOutput } from "./step-runner";
21
23
  import {
22
24
  closeLogFile,
@@ -464,15 +466,19 @@ async function buildAllImages(
464
466
  * can be restarted independently.
465
467
  */
466
468
  export function serviceDockerRunArgs(opts: {
469
+ signingKey?: string;
470
+ bootstrapSecret?: string;
467
471
  cesServiceToken?: string;
468
472
  extraAssistantEnv?: Record<string, string>;
469
473
  gatewayPort: number;
470
474
  imageTags: Record<ServiceName, string>;
475
+ defaultWorkspaceConfigPath?: string;
471
476
  instanceName: string;
472
477
  res: ReturnType<typeof dockerResourceNames>;
473
478
  }): Record<ServiceName, () => string[]> {
474
479
  const {
475
480
  cesServiceToken,
481
+ defaultWorkspaceConfigPath,
476
482
  extraAssistantEnv,
477
483
  gatewayPort,
478
484
  imageTags,
@@ -495,6 +501,8 @@ export function serviceDockerRunArgs(opts: {
495
501
  "-e",
496
502
  `VELLUM_ASSISTANT_NAME=${instanceName}`,
497
503
  "-e",
504
+ "VELLUM_CLOUD=docker",
505
+ "-e",
498
506
  "RUNTIME_HTTP_HOST=0.0.0.0",
499
507
  "-e",
500
508
  "WORKSPACE_DIR=/workspace",
@@ -503,9 +511,21 @@ export function serviceDockerRunArgs(opts: {
503
511
  "-e",
504
512
  `GATEWAY_INTERNAL_URL=http://${res.gatewayContainer}:${GATEWAY_INTERNAL_PORT}`,
505
513
  ];
514
+ if (defaultWorkspaceConfigPath) {
515
+ const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
516
+ args.push(
517
+ "-v",
518
+ `${defaultWorkspaceConfigPath}:${containerPath}:ro`,
519
+ "-e",
520
+ `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH=${containerPath}`,
521
+ );
522
+ }
506
523
  if (cesServiceToken) {
507
524
  args.push("-e", `CES_SERVICE_TOKEN=${cesServiceToken}`);
508
525
  }
526
+ if (opts.signingKey) {
527
+ args.push("-e", `ACTOR_TOKEN_SIGNING_KEY=${opts.signingKey}`);
528
+ }
509
529
  for (const envVar of [
510
530
  ...Object.values(PROVIDER_ENV_VAR_NAMES),
511
531
  "VELLUM_PLATFORM_URL",
@@ -552,6 +572,12 @@ export function serviceDockerRunArgs(opts: {
552
572
  ...(cesServiceToken
553
573
  ? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
554
574
  : []),
575
+ ...(opts.signingKey
576
+ ? ["-e", `ACTOR_TOKEN_SIGNING_KEY=${opts.signingKey}`]
577
+ : []),
578
+ ...(opts.bootstrapSecret
579
+ ? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
580
+ : []),
555
581
  imageTags.gateway,
556
582
  ],
557
583
  "credential-executor": () => [
@@ -735,10 +761,13 @@ export const SERVICE_START_ORDER: ServiceName[] = [
735
761
  /** Start all three containers in dependency order. */
736
762
  export async function startContainers(
737
763
  opts: {
764
+ signingKey?: string;
765
+ bootstrapSecret?: string;
738
766
  cesServiceToken?: string;
739
767
  extraAssistantEnv?: Record<string, string>;
740
768
  gatewayPort: number;
741
769
  imageTags: Record<ServiceName, string>;
770
+ defaultWorkspaceConfigPath?: string;
742
771
  instanceName: string;
743
772
  res: ReturnType<typeof dockerResourceNames>;
744
773
  },
@@ -760,6 +789,7 @@ export async function stopContainers(
760
789
  await removeContainer(res.assistantContainer);
761
790
  }
762
791
 
792
+
763
793
  /** Stop containers without removing them (preserves state for `docker start`). */
764
794
  export async function sleepContainers(
765
795
  res: ReturnType<typeof dockerResourceNames>,
@@ -771,8 +801,14 @@ export async function sleepContainers(
771
801
  ]) {
772
802
  try {
773
803
  await exec("docker", ["stop", container]);
774
- } catch {
775
- // container may not exist or already stopped
804
+ } catch (err) {
805
+ const msg =
806
+ err instanceof Error ? err.message.toLowerCase() : String(err);
807
+ if (msg.includes("no such container") || msg.includes("is not running")) {
808
+ // container doesn't exist or already stopped — expected, skip
809
+ continue;
810
+ }
811
+ throw err;
776
812
  }
777
813
  }
778
814
  }
@@ -996,6 +1032,7 @@ export async function hatchDocker(
996
1032
  detached: boolean,
997
1033
  name: string | null,
998
1034
  watch: boolean = false,
1035
+ configValues: Record<string, string> = {},
999
1036
  ): Promise<void> {
1000
1037
  resetLogFile("hatch.log");
1001
1038
 
@@ -1042,14 +1079,16 @@ export async function hatchDocker(
1042
1079
  } else {
1043
1080
  const version = cliPkg.version;
1044
1081
  const versionTag = version ? `v${version}` : "latest";
1045
- imageTags.assistant = `${DOCKERHUB_IMAGES.assistant}:${versionTag}`;
1046
- imageTags.gateway = `${DOCKERHUB_IMAGES.gateway}:${versionTag}`;
1082
+ log("🔍 Resolving image references...");
1083
+ const resolved = await resolveImageRefs(versionTag, log);
1084
+ imageTags.assistant = resolved.imageTags.assistant;
1085
+ imageTags.gateway = resolved.imageTags.gateway;
1047
1086
  imageTags["credential-executor"] =
1048
- `${DOCKERHUB_IMAGES["credential-executor"]}:${versionTag}`;
1087
+ resolved.imageTags["credential-executor"];
1049
1088
 
1050
1089
  log(`🥚 Hatching Docker assistant: ${instanceName}`);
1051
1090
  log(` Species: ${species}`);
1052
- log(` Images:`);
1091
+ log(` Images (${resolved.source}):`);
1053
1092
  log(` assistant: ${imageTags.assistant}`);
1054
1093
  log(` gateway: ${imageTags.gateway}`);
1055
1094
  log(` credential-executor: ${imageTags["credential-executor"]}`);
@@ -1071,9 +1110,37 @@ export async function hatchDocker(
1071
1110
  await exec("docker", ["volume", "create", res.cesSecurityVolume]);
1072
1111
  await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
1073
1112
 
1113
+ // Set workspace volume ownership so non-root containers (UID 1001) can write.
1114
+ await exec("docker", [
1115
+ "run",
1116
+ "--rm",
1117
+ "-v",
1118
+ `${res.workspaceVolume}:/workspace`,
1119
+ "busybox",
1120
+ "chown",
1121
+ "1001:1001",
1122
+ "/workspace",
1123
+ ]);
1124
+
1125
+ // Write --config key=value pairs to a temp file that gets bind-mounted
1126
+ // into the assistant container and read via VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH.
1127
+ const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
1128
+
1074
1129
  const cesServiceToken = randomBytes(32).toString("hex");
1130
+ const signingKey = randomBytes(32).toString("hex");
1131
+ const bootstrapSecret = randomBytes(32).toString("hex");
1132
+ saveBootstrapSecret(instanceName, bootstrapSecret);
1075
1133
  await startContainers(
1076
- { cesServiceToken, gatewayPort, imageTags, instanceName, res },
1134
+ {
1135
+ signingKey,
1136
+ bootstrapSecret,
1137
+ cesServiceToken,
1138
+ gatewayPort,
1139
+ imageTags,
1140
+ defaultWorkspaceConfigPath,
1141
+ instanceName,
1142
+ res,
1143
+ },
1077
1144
  log,
1078
1145
  );
1079
1146
 
@@ -1252,7 +1319,9 @@ async function waitForGatewayAndLease(opts: {
1252
1319
  // Log periodically so the user knows we're still trying
1253
1320
  const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
1254
1321
  log(
1255
- `Guardian token lease: attempt failed after ${elapsed}s (${lastLeaseError.split("\n")[0]}), retrying...`,
1322
+ `Guardian token lease: attempt failed after ${elapsed}s (${
1323
+ lastLeaseError.split("\n")[0]
1324
+ }), retrying...`,
1256
1325
  );
1257
1326
  }
1258
1327
  await new Promise((r) => setTimeout(r, 2000));
@@ -1260,7 +1329,10 @@ async function waitForGatewayAndLease(opts: {
1260
1329
 
1261
1330
  if (!leaseSuccess) {
1262
1331
  log(
1263
- `\u26a0\ufe0f Guardian token lease: FAILED after ${((Date.now() - leaseStart) / 1000).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
1332
+ `\u26a0\ufe0f Guardian token lease: FAILED after ${(
1333
+ (Date.now() - leaseStart) /
1334
+ 1000
1335
+ ).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
1264
1336
  );
1265
1337
  }
1266
1338
 
@@ -1,4 +1,4 @@
1
- const DOCTOR_URL = "https://doctor.vellum.ai";
1
+ const DOCTOR_URL = process.env.DOCTOR_SERVICE_URL?.trim() || "";
2
2
 
3
3
  export type ProgressPhase =
4
4
  | "invoking_prompt"
@@ -107,6 +107,16 @@ async function callDoctorDaemon(
107
107
  chatContext?: ChatLogEntry[],
108
108
  onLog?: DoctorLogCallback,
109
109
  ): Promise<DoctorResult> {
110
+ if (!DOCTOR_URL) {
111
+ onLog?.("Doctor service not configured (DOCTOR_SERVICE_URL is not set)");
112
+ return {
113
+ assistantId,
114
+ diagnostics: null,
115
+ recommendation: null,
116
+ error: "Doctor service not configured",
117
+ };
118
+ }
119
+
110
120
  const MAX_RETRIES = 2;
111
121
  let lastError: unknown;
112
122
 
package/src/lib/gcp.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "./constants";
12
12
  import type { Species } from "./constants";
13
13
  import { leaseGuardianToken } from "./guardian-token";
14
+ import { getPlatformUrl } from "./platform-client";
14
15
  import { generateInstanceName } from "./random-name";
15
16
  import { exec, execOutput } from "./step-runner";
16
17
 
@@ -455,6 +456,7 @@ export async function hatchGcp(
455
456
  providerApiKeys: Record<string, string>,
456
457
  instanceName: string,
457
458
  cloud: "gcp",
459
+ configValues?: Record<string, string>,
458
460
  ) => Promise<string>,
459
461
  watchHatching: (
460
462
  pollFn: () => Promise<PollResult>,
@@ -462,6 +464,7 @@ export async function hatchGcp(
462
464
  startTime: number,
463
465
  species: Species,
464
466
  ) => Promise<WatchHatchingResult>,
467
+ configValues: Record<string, string> = {},
465
468
  ): Promise<void> {
466
469
  const startTime = Date.now();
467
470
  const account = process.env.GCP_ACCOUNT_EMAIL;
@@ -525,6 +528,7 @@ export async function hatchGcp(
525
528
  providerApiKeys,
526
529
  instanceName,
527
530
  "gcp",
531
+ configValues,
528
532
  );
529
533
  const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
530
534
  writeFileSync(startupScriptPath, startupScript);
@@ -640,7 +644,7 @@ export async function hatchGcp(
640
644
  species === "vellum" &&
641
645
  (await checkCurlFailure(instanceName, project, zone, account))
642
646
  ) {
643
- const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://vellum.ai"}/install.sh`;
647
+ const installScriptUrl = `${getPlatformUrl()}/install.sh`;
644
648
  console.log(
645
649
  `\ud83d\udd04 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`,
646
650
  );
@@ -42,6 +42,46 @@ function getPersistedDeviceIdPath(): string {
42
42
  return join(getXdgConfigHome(), "vellum", "device-id");
43
43
  }
44
44
 
45
+ function getBootstrapSecretPath(assistantId: string): string {
46
+ return join(
47
+ getXdgConfigHome(),
48
+ "vellum",
49
+ "assistants",
50
+ assistantId,
51
+ "bootstrap-secret",
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Load a previously saved bootstrap secret for the given assistant.
57
+ * Returns null if the file does not exist or is unreadable.
58
+ */
59
+ export function loadBootstrapSecret(assistantId: string): string | null {
60
+ try {
61
+ const raw = readFileSync(getBootstrapSecretPath(assistantId), "utf-8").trim();
62
+ return raw.length > 0 ? raw : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Persist a bootstrap secret for the given assistant so that the desktop
70
+ * client and upgrade/rollback paths can retrieve it later.
71
+ */
72
+ export function saveBootstrapSecret(
73
+ assistantId: string,
74
+ secret: string,
75
+ ): void {
76
+ const path = getBootstrapSecretPath(assistantId);
77
+ const dir = dirname(path);
78
+ if (!existsSync(dir)) {
79
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
80
+ }
81
+ writeFileSync(path, secret + "\n", { mode: 0o600 });
82
+ chmodSync(path, 0o600);
83
+ }
84
+
45
85
  function hashWithSalt(input: string): string {
46
86
  return createHash("sha256")
47
87
  .update(input + DEVICE_ID_SALT)
@@ -168,9 +208,14 @@ export async function leaseGuardianToken(
168
208
  assistantId: string,
169
209
  ): Promise<GuardianTokenData> {
170
210
  const deviceId = computeDeviceId();
211
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
212
+ const bootstrapSecret = loadBootstrapSecret(assistantId);
213
+ if (bootstrapSecret) {
214
+ headers["x-bootstrap-secret"] = bootstrapSecret;
215
+ }
171
216
  const response = await fetch(`${gatewayUrl}/v1/guardian/init`, {
172
217
  method: "POST",
173
- headers: { "Content-Type": "application/json" },
218
+ headers,
174
219
  body: JSON.stringify({ platform: "cli", deviceId }),
175
220
  });
176
221
 
@@ -3,11 +3,13 @@ export const HEALTH_CHECK_TIMEOUT_MS = 1500;
3
3
  interface HealthResponse {
4
4
  status: string;
5
5
  message?: string;
6
+ version?: string;
6
7
  }
7
8
 
8
9
  export interface HealthCheckResult {
9
10
  status: string;
10
11
  detail: string | null;
12
+ version?: string;
11
13
  }
12
14
 
13
15
  export async function checkManagedHealth(
@@ -63,6 +65,7 @@ export async function checkManagedHealth(
63
65
  return {
64
66
  status,
65
67
  detail: status !== "healthy" ? (data.message ?? null) : null,
68
+ version: data.version,
66
69
  };
67
70
  } catch (error) {
68
71
  const status =
@@ -108,6 +111,7 @@ export async function checkHealth(
108
111
  return {
109
112
  status,
110
113
  detail: status !== "healthy" ? (data.message ?? null) : null,
114
+ version: data.version,
111
115
  };
112
116
  } catch (error) {
113
117
  const status =
package/src/lib/local.ts CHANGED
@@ -192,10 +192,15 @@ function resolveDaemonMainPath(assistantIndex: string): string {
192
192
  return join(dirname(assistantIndex), "daemon", "main.ts");
193
193
  }
194
194
 
195
+ type DaemonStartOptions = {
196
+ foreground?: boolean;
197
+ defaultWorkspaceConfigPath?: string;
198
+ };
199
+
195
200
  async function startDaemonFromSource(
196
201
  assistantIndex: string,
197
202
  resources: LocalInstanceResources,
198
- options?: { foreground?: boolean },
203
+ options?: DaemonStartOptions,
199
204
  ): Promise<void> {
200
205
  const foreground = options?.foreground ?? false;
201
206
  const daemonMainPath = resolveDaemonMainPath(assistantIndex);
@@ -260,6 +265,7 @@ async function startDaemonFromSource(
260
265
  const env: Record<string, string | undefined> = {
261
266
  ...process.env,
262
267
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
268
+ VELLUM_CLOUD: "local",
263
269
  };
264
270
  if (resources) {
265
271
  env.BASE_DATA_DIR = resources.instanceDir;
@@ -268,6 +274,10 @@ async function startDaemonFromSource(
268
274
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
269
275
  delete env.QDRANT_URL;
270
276
  }
277
+ if (options?.defaultWorkspaceConfigPath) {
278
+ env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
279
+ options.defaultWorkspaceConfigPath;
280
+ }
271
281
 
272
282
  // Write a sentinel PID file before spawning so concurrent hatch() calls
273
283
  // detect the in-progress spawn and wait instead of racing.
@@ -306,6 +316,7 @@ async function startDaemonFromSource(
306
316
  async function startDaemonWatchFromSource(
307
317
  assistantIndex: string,
308
318
  resources: LocalInstanceResources,
319
+ options?: DaemonStartOptions,
309
320
  ): Promise<void> {
310
321
  const mainPath = resolveDaemonMainPath(assistantIndex);
311
322
  if (!existsSync(mainPath)) {
@@ -381,6 +392,10 @@ async function startDaemonWatchFromSource(
381
392
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
382
393
  delete env.QDRANT_URL;
383
394
  }
395
+ if (options?.defaultWorkspaceConfigPath) {
396
+ env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
397
+ options.defaultWorkspaceConfigPath;
398
+ }
384
399
 
385
400
  // Write a sentinel PID file before spawning so concurrent hatch() calls
386
401
  // detect the in-progress spawn and wait instead of racing.
@@ -819,7 +834,7 @@ export function isGatewayWatchModeAvailable(): boolean {
819
834
  export async function startLocalDaemon(
820
835
  watch: boolean = false,
821
836
  resources: LocalInstanceResources,
822
- options?: { foreground?: boolean },
837
+ options?: DaemonStartOptions,
823
838
  ): Promise<void> {
824
839
  const foreground = options?.foreground ?? false;
825
840
  // Check for a compiled daemon binary adjacent to the CLI executable.
@@ -905,7 +920,7 @@ export async function startLocalDaemon(
905
920
 
906
921
  // Build a minimal environment for the daemon. When launched from the
907
922
  // macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
908
- // __CFBundleIdentifier, CLAUDE_CODE_ENTRYPOINT, etc.) that can cause
923
+ // __CFBundleIdentifier, etc.) that can cause
909
924
  // the daemon to take 50+ seconds to start instead of ~1s.
910
925
  const home = homedir();
911
926
  const bunBinDir = join(home, ".bun", "bin");
@@ -924,20 +939,25 @@ export async function startLocalDaemon(
924
939
  "ANTHROPIC_API_KEY",
925
940
  "APP_VERSION",
926
941
  "BASE_DATA_DIR",
927
- "PLATFORM_BASE_URL",
942
+ "VELLUM_PLATFORM_URL",
928
943
  "QDRANT_HTTP_PORT",
929
944
  "QDRANT_URL",
930
945
  "RUNTIME_HTTP_PORT",
931
- "SENTRY_DSN",
946
+ "SENTRY_DSN_ASSISTANT",
932
947
  "TMPDIR",
933
948
  "USER",
934
949
  "LANG",
935
950
  "VELLUM_DEBUG",
951
+ "VELLUM_DESKTOP_APP",
936
952
  ]) {
937
953
  if (process.env[key]) {
938
954
  daemonEnv[key] = process.env[key]!;
939
955
  }
940
956
  }
957
+ if (options?.defaultWorkspaceConfigPath) {
958
+ daemonEnv.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
959
+ options.defaultWorkspaceConfigPath;
960
+ }
941
961
  // When running a named instance, override env so the daemon resolves
942
962
  // all paths under the instance directory and listens on its own port.
943
963
  if (resources) {
@@ -1005,9 +1025,9 @@ export async function startLocalDaemon(
1005
1025
  // Kill the bundled daemon to avoid two processes competing for the same port
1006
1026
  await stopProcessByPidFile(pidFile, "bundled daemon");
1007
1027
  if (watch) {
1008
- await startDaemonWatchFromSource(assistantIndex, resources);
1028
+ await startDaemonWatchFromSource(assistantIndex, resources, options);
1009
1029
  } else {
1010
- await startDaemonFromSource(assistantIndex, resources);
1030
+ await startDaemonFromSource(assistantIndex, resources, options);
1011
1031
  }
1012
1032
  daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
1013
1033
  }
@@ -1031,7 +1051,7 @@ export async function startLocalDaemon(
1031
1051
  );
1032
1052
  }
1033
1053
  if (watch) {
1034
- await startDaemonWatchFromSource(assistantIndex, resources);
1054
+ await startDaemonWatchFromSource(assistantIndex, resources, options);
1035
1055
 
1036
1056
  const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
1037
1057
  if (daemonReady) {
@@ -1042,7 +1062,7 @@ export async function startLocalDaemon(
1042
1062
  );
1043
1063
  }
1044
1064
  } else {
1045
- await startDaemonFromSource(assistantIndex, resources, { foreground });
1065
+ await startDaemonFromSource(assistantIndex, resources, options);
1046
1066
 
1047
1067
  const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
1048
1068
  if (daemonReady) {
@@ -9,7 +9,7 @@ import {
9
9
  import { homedir } from "os";
10
10
  import { join, dirname } from "path";
11
11
 
12
- const DEFAULT_PLATFORM_URL = "https://platform.vellum.ai";
12
+ const DEFAULT_PLATFORM_URL = "";
13
13
 
14
14
  function getXdgConfigHome(): string {
15
15
  return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
@@ -23,6 +23,20 @@ export function getPlatformUrl(): string {
23
23
  return process.env.VELLUM_PLATFORM_URL ?? DEFAULT_PLATFORM_URL;
24
24
  }
25
25
 
26
+ /**
27
+ * Returns the platform URL, throwing a clear error if it is not configured.
28
+ * Use this in functions that need a valid URL to make HTTP requests.
29
+ */
30
+ function requirePlatformUrl(): string {
31
+ const url = getPlatformUrl();
32
+ if (!url) {
33
+ throw new Error(
34
+ "VELLUM_PLATFORM_URL is not configured. Set it in your environment or .env file.",
35
+ );
36
+ }
37
+ return url;
38
+ }
39
+
26
40
  export function readPlatformToken(): string | null {
27
41
  try {
28
42
  return readFileSync(getPlatformTokenPath(), "utf-8").trim();
@@ -60,14 +74,15 @@ interface OrganizationListResponse {
60
74
  }
61
75
 
62
76
  export async function fetchOrganizationId(token: string): Promise<string> {
63
- const url = `${getPlatformUrl()}/v1/organizations/`;
77
+ const platformUrl = requirePlatformUrl();
78
+ const url = `${platformUrl}/v1/organizations/`;
64
79
  const response = await fetch(url, {
65
80
  headers: { "X-Session-Token": token },
66
81
  });
67
82
 
68
83
  if (!response.ok) {
69
84
  throw new Error(
70
- `Failed to fetch organizations (${response.status}). Try logging in again.`,
85
+ `Failed to fetch organizations from ${platformUrl} (${response.status}). Try logging in again.`,
71
86
  );
72
87
  }
73
88
 
@@ -91,7 +106,7 @@ interface AllauthSessionResponse {
91
106
  }
92
107
 
93
108
  export async function fetchCurrentUser(token: string): Promise<PlatformUser> {
94
- const url = `${getPlatformUrl()}/_allauth/app/v1/auth/session`;
109
+ const url = `${requirePlatformUrl()}/_allauth/app/v1/auth/session`;
95
110
  const response = await fetch(url, {
96
111
  headers: { "X-Session-Token": token },
97
112
  });
@@ -0,0 +1,112 @@
1
+ import { getPlatformUrl } from "./platform-client.js";
2
+ import { DOCKERHUB_IMAGES } from "./docker.js";
3
+ import type { ServiceName } from "./docker.js";
4
+
5
+ export interface ResolvedImageRefs {
6
+ imageTags: Record<ServiceName, string>;
7
+ source: "platform" | "dockerhub";
8
+ }
9
+
10
+ /**
11
+ * Resolve image references for a given version.
12
+ *
13
+ * Tries the platform API first (returns GCR digest-based refs when available),
14
+ * then falls back to DockerHub tag-based refs when the platform is unreachable
15
+ * or the version is not found.
16
+ */
17
+ export async function resolveImageRefs(
18
+ version: string,
19
+ log?: (msg: string) => void,
20
+ ): Promise<ResolvedImageRefs> {
21
+ log?.("Resolving image references...");
22
+
23
+ const platformRefs = await fetchPlatformImageRefs(version, log);
24
+ if (platformRefs) {
25
+ log?.("Resolved image refs from platform API");
26
+ return { imageTags: platformRefs, source: "platform" };
27
+ }
28
+
29
+ log?.("Falling back to DockerHub tags");
30
+ const imageTags: Record<ServiceName, string> = {
31
+ assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
32
+ "credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
33
+ gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
34
+ };
35
+ return { imageTags, source: "dockerhub" };
36
+ }
37
+
38
+ /**
39
+ * Fetch image references from the platform releases API.
40
+ *
41
+ * Returns a record of service name to image ref (GCR digest-based) for the
42
+ * given version, or null if the platform is unreachable, the version is not
43
+ * found, or any error occurs.
44
+ */
45
+ async function fetchPlatformImageRefs(
46
+ version: string,
47
+ log?: (msg: string) => void,
48
+ ): Promise<Record<ServiceName, string> | null> {
49
+ try {
50
+ const platformUrl = getPlatformUrl();
51
+ const url = `${platformUrl}/v1/releases/?stable=true`;
52
+
53
+ log?.(`Fetching releases from ${url}`);
54
+
55
+ const response = await fetch(url, {
56
+ signal: AbortSignal.timeout(10_000),
57
+ });
58
+
59
+ if (!response.ok) {
60
+ log?.(`Platform API returned ${response.status}`);
61
+ return null;
62
+ }
63
+
64
+ const releases = (await response.json()) as Array<{
65
+ version?: string;
66
+ assistant_image_ref?: string | null;
67
+ gateway_image_ref?: string | null;
68
+ credential_executor_image_ref?: string | null;
69
+ }>;
70
+
71
+ // Strip leading "v" from the requested version for matching
72
+ const normalizedVersion = version.replace(/^v/, "");
73
+
74
+ const release = releases.find((r) => {
75
+ const releaseVersion = (r.version ?? "").replace(/^v/, "");
76
+ return releaseVersion === normalizedVersion;
77
+ });
78
+
79
+ if (!release) {
80
+ log?.(`Version ${version} not found in platform releases`);
81
+ return null;
82
+ }
83
+
84
+ const assistantImage = release.assistant_image_ref;
85
+ const gatewayImage = release.gateway_image_ref;
86
+ let credentialExecutorImage = release.credential_executor_image_ref;
87
+
88
+ // Assistant and gateway images are required; credential-executor falls back to DockerHub
89
+ if (!assistantImage || !gatewayImage) {
90
+ log?.("Platform release missing required image refs");
91
+ return null;
92
+ }
93
+
94
+ // Fall back to DockerHub for credential-executor if its image ref is null
95
+ if (!credentialExecutorImage) {
96
+ credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
97
+ log?.(
98
+ "credential-executor image not in platform release, using DockerHub fallback",
99
+ );
100
+ }
101
+
102
+ return {
103
+ assistant: assistantImage,
104
+ "credential-executor": credentialExecutorImage,
105
+ gateway: gatewayImage,
106
+ };
107
+ } catch (err) {
108
+ const message = err instanceof Error ? err.message : String(err);
109
+ log?.(`Platform image ref resolution failed: ${message}`);
110
+ return null;
111
+ }
112
+ }