@vellumai/cli 0.5.16 → 0.6.0

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.
@@ -20,6 +20,10 @@ import {
20
20
  platformDownloadExport,
21
21
  platformImportPreflight,
22
22
  platformImportBundle,
23
+ platformRequestUploadUrl,
24
+ platformUploadToSignedUrl,
25
+ platformImportPreflightFromGcs,
26
+ platformImportBundleFromGcs,
23
27
  } from "../lib/platform-client.js";
24
28
  import {
25
29
  hatchDocker,
@@ -628,6 +632,7 @@ async function importToAssistant(
628
632
  cloud: string,
629
633
  bundleData: Uint8Array<ArrayBuffer>,
630
634
  dryRun: boolean,
635
+ preUploadedBundleKey?: string | null,
631
636
  ): Promise<void> {
632
637
  if (cloud === "vellum") {
633
638
  // Platform target
@@ -649,6 +654,32 @@ async function importToAssistant(
649
654
  throw err;
650
655
  }
651
656
 
657
+ // Use pre-uploaded bundle key if provided (string), skip upload if null
658
+ // (signals signed URLs were already tried and unavailable), or try
659
+ // signed-URL upload if undefined (never attempted).
660
+ let bundleKey: string | undefined =
661
+ preUploadedBundleKey === null ? undefined : preUploadedBundleKey;
662
+ if (preUploadedBundleKey === undefined) {
663
+ try {
664
+ const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
665
+ token,
666
+ orgId,
667
+ entry.runtimeUrl,
668
+ );
669
+ bundleKey = key;
670
+ console.log("Uploading bundle...");
671
+ await platformUploadToSignedUrl(uploadUrl, bundleData);
672
+ } catch (err) {
673
+ // If signed uploads unavailable (503), fall back to inline upload
674
+ const msg = err instanceof Error ? err.message : String(err);
675
+ if (msg.includes("not available")) {
676
+ bundleKey = undefined;
677
+ } else {
678
+ throw err;
679
+ }
680
+ }
681
+ }
682
+
652
683
  if (dryRun) {
653
684
  console.log("Running preflight analysis...\n");
654
685
 
@@ -657,12 +688,19 @@ async function importToAssistant(
657
688
  body: Record<string, unknown>;
658
689
  };
659
690
  try {
660
- preflightResult = await platformImportPreflight(
661
- bundleData,
662
- token,
663
- orgId,
664
- entry.runtimeUrl,
665
- );
691
+ preflightResult = bundleKey
692
+ ? await platformImportPreflightFromGcs(
693
+ bundleKey,
694
+ token,
695
+ orgId,
696
+ entry.runtimeUrl,
697
+ )
698
+ : await platformImportPreflight(
699
+ bundleData,
700
+ token,
701
+ orgId,
702
+ entry.runtimeUrl,
703
+ );
666
704
  } catch (err) {
667
705
  if (err instanceof Error && err.name === "TimeoutError") {
668
706
  console.error("Error: Preflight request timed out after 2 minutes.");
@@ -712,12 +750,19 @@ async function importToAssistant(
712
750
 
713
751
  let importResult: { statusCode: number; body: Record<string, unknown> };
714
752
  try {
715
- importResult = await platformImportBundle(
716
- bundleData,
717
- token,
718
- orgId,
719
- entry.runtimeUrl,
720
- );
753
+ importResult = bundleKey
754
+ ? await platformImportBundleFromGcs(
755
+ bundleKey,
756
+ token,
757
+ orgId,
758
+ entry.runtimeUrl,
759
+ )
760
+ : await platformImportBundle(
761
+ bundleData,
762
+ token,
763
+ orgId,
764
+ entry.runtimeUrl,
765
+ );
721
766
  } catch (err) {
722
767
  if (err instanceof Error && err.name === "TimeoutError") {
723
768
  console.error("Error: Import request timed out after 2 minutes.");
@@ -751,6 +796,7 @@ async function importToAssistant(
751
796
  export async function resolveOrHatchTarget(
752
797
  targetEnv: "local" | "docker" | "platform",
753
798
  targetName?: string,
799
+ orgId?: string,
754
800
  ): Promise<AssistantEntry> {
755
801
  // If a name is provided, try to find an existing assistant
756
802
  if (targetName) {
@@ -830,7 +876,25 @@ export async function resolveOrHatchTarget(
830
876
  process.exit(1);
831
877
  }
832
878
 
833
- const result = await hatchAssistant(token);
879
+ let resolvedOrgId: string;
880
+ if (orgId) {
881
+ resolvedOrgId = orgId;
882
+ } else {
883
+ try {
884
+ resolvedOrgId = await fetchOrganizationId(token);
885
+ } catch (err) {
886
+ const msg = err instanceof Error ? err.message : String(err);
887
+ if (msg.includes("401") || msg.includes("403")) {
888
+ console.error(
889
+ "Authentication failed. Run 'vellum login' to refresh.",
890
+ );
891
+ process.exit(1);
892
+ }
893
+ throw err;
894
+ }
895
+ }
896
+
897
+ const result = await hatchAssistant(token, resolvedOrgId);
834
898
  const entry: AssistantEntry = {
835
899
  assistantId: result.id,
836
900
  runtimeUrl: getPlatformUrl(),
@@ -1080,10 +1144,19 @@ export async function teleport(): Promise<void> {
1080
1144
  // No existing target — just describe what would happen
1081
1145
  console.log("Dry run summary:");
1082
1146
  console.log(` Would export data from: ${from} (${fromCloud})`);
1083
- console.log(
1084
- ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1085
- );
1086
- console.log(` Would import data into the new assistant`);
1147
+ if (targetEnv === "platform") {
1148
+ // For platform targets, reflect the reordered flow
1149
+ console.log(` Would upload bundle via signed URL (if available)`);
1150
+ console.log(
1151
+ ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1152
+ );
1153
+ console.log(` Would import data into the new assistant`);
1154
+ } else {
1155
+ console.log(
1156
+ ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1157
+ );
1158
+ console.log(` Would import data into the new assistant`);
1159
+ }
1087
1160
  }
1088
1161
 
1089
1162
  console.log(`Dry run complete — no changes were made.`);
@@ -1094,6 +1167,84 @@ export async function teleport(): Promise<void> {
1094
1167
  console.log(`Exporting from ${from} (${fromCloud})...`);
1095
1168
  const bundleData = await exportFromAssistant(fromEntry, fromCloud);
1096
1169
 
1170
+ // Platform target: reordered flow — upload to GCS before hatching so that
1171
+ // if upload fails, no empty assistant is left dangling on the platform.
1172
+ if (targetEnv === "platform") {
1173
+ // Step B — Auth + Org ID
1174
+ const token = readPlatformToken();
1175
+ if (!token) {
1176
+ console.error("Not logged in. Run 'vellum login' first.");
1177
+ process.exit(1);
1178
+ }
1179
+
1180
+ // If targeting an existing assistant, validate cloud match early — before
1181
+ // uploading — so we don't waste a GCS upload on an invalid command.
1182
+ const existingTarget = targetName ? findAssistantByName(targetName) : null;
1183
+ if (existingTarget) {
1184
+ const existingCloud = resolveCloud(existingTarget);
1185
+ if (existingCloud !== "vellum") {
1186
+ console.error(
1187
+ `Error: Assistant '${targetName}' is a ${existingCloud} assistant, not platform. ` +
1188
+ `Use --${existingCloud} to target it.`,
1189
+ );
1190
+ process.exit(1);
1191
+ }
1192
+ }
1193
+
1194
+ // Use the existing target's runtimeUrl for all platform calls so upload,
1195
+ // org ID fetch, and import hit the same instance.
1196
+ const targetPlatformUrl = existingTarget?.runtimeUrl;
1197
+
1198
+ let orgId: string;
1199
+ try {
1200
+ orgId = await fetchOrganizationId(token, targetPlatformUrl);
1201
+ } catch (err) {
1202
+ const msg = err instanceof Error ? err.message : String(err);
1203
+ if (msg.includes("401") || msg.includes("403")) {
1204
+ console.error("Authentication failed. Run 'vellum login' to refresh.");
1205
+ process.exit(1);
1206
+ }
1207
+ throw err;
1208
+ }
1209
+
1210
+ // Step C — Upload to GCS
1211
+ // bundleKey: string = uploaded successfully, null = tried but unavailable,
1212
+ // undefined would mean "never tried" (not used here).
1213
+ let bundleKey: string | null = null;
1214
+ try {
1215
+ const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
1216
+ token,
1217
+ orgId,
1218
+ targetPlatformUrl,
1219
+ );
1220
+ bundleKey = key;
1221
+ console.log("Uploading bundle to GCS...");
1222
+ await platformUploadToSignedUrl(uploadUrl, bundleData);
1223
+ } catch (err) {
1224
+ // If signed uploads unavailable (503), fall back to inline upload later
1225
+ const msg = err instanceof Error ? err.message : String(err);
1226
+ if (msg.includes("not available")) {
1227
+ bundleKey = null;
1228
+ } else {
1229
+ throw err;
1230
+ }
1231
+ }
1232
+
1233
+ // Step D — Hatch (upload succeeded or fallback to inline — safe to hatch)
1234
+ const toEntry = await resolveOrHatchTarget(targetEnv, targetName, orgId);
1235
+ const toCloud = resolveCloud(toEntry);
1236
+
1237
+ // Step E — Import from GCS (or inline fallback)
1238
+ // Pass bundleKey (string) or null to signal "already tried, use inline".
1239
+ console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1240
+ await importToAssistant(toEntry, toCloud, bundleData, false, bundleKey);
1241
+
1242
+ // Success summary
1243
+ console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
1244
+ return;
1245
+ }
1246
+
1247
+ // Non-platform targets (local/docker): existing flow unchanged
1097
1248
  // For local<->docker transfers, stop (sleep) the source to free up ports
1098
1249
  // before hatching the target. We do NOT retire yet — if hatch or import
1099
1250
  // fails, the user can recover by running `vellum wake <source>`.
@@ -291,6 +291,11 @@ async function upgradeDocker(
291
291
  ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
292
292
  );
293
293
 
294
+ // Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
295
+ // set on gateway, not assistant) so it persists across container restarts.
296
+ const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
297
+ const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
298
+
294
299
  // Notify connected clients that an upgrade is about to begin.
295
300
  // This must fire BEFORE any progress broadcasts so the UI sets
296
301
  // isUpdateInProgress = true and starts displaying status messages.
@@ -419,6 +424,7 @@ async function upgradeDocker(
419
424
  await startContainers(
420
425
  {
421
426
  signingKey,
427
+ bootstrapSecret,
422
428
  cesServiceToken,
423
429
  extraAssistantEnv,
424
430
  gatewayPort,
@@ -517,6 +523,7 @@ async function upgradeDocker(
517
523
  await startContainers(
518
524
  {
519
525
  signingKey,
526
+ bootstrapSecret,
520
527
  cesServiceToken,
521
528
  extraAssistantEnv,
522
529
  gatewayPort,
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";
@@ -479,8 +480,9 @@ async function buildAllImages(
479
480
 
480
481
  /**
481
482
  * 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.
483
+ * service. All three containers share a network namespace via
484
+ * `--network=container:` so inter-service traffic is over localhost,
485
+ * matching the platform's Kubernetes pod topology.
484
486
  */
485
487
  export function serviceDockerRunArgs(opts: {
486
488
  signingKey?: string;
@@ -511,12 +513,14 @@ export function serviceDockerRunArgs(opts: {
511
513
  "--name",
512
514
  res.assistantContainer,
513
515
  `--network=${res.network}`,
516
+ "-p",
517
+ `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
514
518
  "-v",
515
519
  `${res.workspaceVolume}:/workspace`,
516
520
  "-v",
517
521
  `${res.socketVolume}:/run/ces-bootstrap`,
518
522
  "-e",
519
- "IS_CONTAINERIZED=false",
523
+ "IS_CONTAINERIZED=true",
520
524
  "-e",
521
525
  `VELLUM_ASSISTANT_NAME=${instanceName}`,
522
526
  "-e",
@@ -526,9 +530,9 @@ export function serviceDockerRunArgs(opts: {
526
530
  "-e",
527
531
  "VELLUM_WORKSPACE_DIR=/workspace",
528
532
  "-e",
529
- `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
533
+ "CES_CREDENTIAL_URL=http://localhost:8090",
530
534
  "-e",
531
- `GATEWAY_INTERNAL_URL=http://${res.gatewayContainer}:${GATEWAY_INTERNAL_PORT}`,
535
+ `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
532
536
  ];
533
537
  if (defaultWorkspaceConfigPath) {
534
538
  const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
@@ -567,9 +571,7 @@ export function serviceDockerRunArgs(opts: {
567
571
  "-d",
568
572
  "--name",
569
573
  res.gatewayContainer,
570
- `--network=${res.network}`,
571
- "-p",
572
- `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
574
+ `--network=container:${res.assistantContainer}`,
573
575
  "-v",
574
576
  `${res.workspaceVolume}:/workspace`,
575
577
  "-v",
@@ -581,13 +583,13 @@ export function serviceDockerRunArgs(opts: {
581
583
  "-e",
582
584
  `GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
583
585
  "-e",
584
- `ASSISTANT_HOST=${res.assistantContainer}`,
586
+ "ASSISTANT_HOST=localhost",
585
587
  "-e",
586
588
  `RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
587
589
  "-e",
588
590
  "RUNTIME_PROXY_ENABLED=true",
589
591
  "-e",
590
- `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
592
+ "CES_CREDENTIAL_URL=http://localhost:8090",
591
593
  ...(cesServiceToken
592
594
  ? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
593
595
  : []),
@@ -605,7 +607,7 @@ export function serviceDockerRunArgs(opts: {
605
607
  "-d",
606
608
  "--name",
607
609
  res.cesContainer,
608
- `--network=${res.network}`,
610
+ `--network=container:${res.assistantContainer}`,
609
611
  "-v",
610
612
  `${res.socketVolume}:/run/ces-bootstrap`,
611
613
  "-v",
@@ -842,6 +844,15 @@ function startFileWatcher(opts: {
842
844
  const services = pendingServices;
843
845
  pendingServices = new Set();
844
846
 
847
+ // Gateway and CES share the assistant's network namespace. If the
848
+ // assistant container is removed and recreated, the shared namespace
849
+ // is destroyed and the other two lose connectivity. Cascade the
850
+ // restart to all three services in that case.
851
+ if (services.has("assistant")) {
852
+ services.add("gateway");
853
+ services.add("credential-executor");
854
+ }
855
+
845
856
  const serviceNames = [...services].join(", ");
846
857
  console.log(`\n🔄 Changes detected — rebuilding: ${serviceNames}`);
847
858
 
@@ -854,7 +865,10 @@ function startFileWatcher(opts: {
854
865
  }),
855
866
  );
856
867
 
857
- for (const service of services) {
868
+ // Restart in dependency order (assistant first) so the network
869
+ // namespace owner is up before dependents try to attach.
870
+ for (const service of SERVICE_START_ORDER) {
871
+ if (!services.has(service)) continue;
858
872
  const container = containerForService[service];
859
873
  console.log(`🔄 Restarting ${container}...`);
860
874
  await removeContainer(container);
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";
@@ -87,14 +87,20 @@ export interface HatchedAssistant {
87
87
  status: string;
88
88
  }
89
89
 
90
- export async function hatchAssistant(token: string): Promise<HatchedAssistant> {
91
- const url = `${getPlatformUrl()}/v1/assistants/hatch/`;
90
+ export async function hatchAssistant(
91
+ token: string,
92
+ orgId: string,
93
+ platformUrl?: string,
94
+ ): Promise<HatchedAssistant> {
95
+ const resolvedUrl = platformUrl || getPlatformUrl();
96
+ const url = `${resolvedUrl}/v1/assistants/hatch/`;
92
97
 
93
98
  const response = await fetch(url, {
94
99
  method: "POST",
95
100
  headers: {
96
101
  "Content-Type": "application/json",
97
102
  ...authHeaders(token),
103
+ "Vellum-Organization-Id": orgId,
98
104
  },
99
105
  body: JSON.stringify({}),
100
106
  });
@@ -143,7 +149,7 @@ export async function fetchOrganizationId(
143
149
  const resolvedUrl = platformUrl || getPlatformUrl();
144
150
  const url = `${resolvedUrl}/v1/organizations/`;
145
151
  const response = await fetch(url, {
146
- headers: { "X-Session-Token": token },
152
+ headers: { ...authHeaders(token) },
147
153
  });
148
154
 
149
155
  if (!response.ok) {
@@ -213,7 +219,7 @@ export async function rollbackPlatformAssistant(
213
219
  method: "POST",
214
220
  headers: {
215
221
  "Content-Type": "application/json",
216
- "X-Session-Token": token,
222
+ ...authHeaders(token),
217
223
  "Vellum-Organization-Id": orgId,
218
224
  },
219
225
  body: JSON.stringify(version ? { version } : {}),
@@ -258,7 +264,7 @@ export async function platformInitiateExport(
258
264
  method: "POST",
259
265
  headers: {
260
266
  "Content-Type": "application/json",
261
- "X-Session-Token": token,
267
+ ...authHeaders(token),
262
268
  "Vellum-Organization-Id": orgId,
263
269
  },
264
270
  body: JSON.stringify({ description: description ?? "CLI backup" }),
@@ -292,7 +298,7 @@ export async function platformPollExportStatus(
292
298
  `${resolvedUrl}/v1/migrations/export/${jobId}/status/`,
293
299
  {
294
300
  headers: {
295
- "X-Session-Token": token,
301
+ ...authHeaders(token),
296
302
  "Vellum-Organization-Id": orgId,
297
303
  },
298
304
  },
@@ -349,7 +355,7 @@ export async function platformImportPreflight(
349
355
  method: "POST",
350
356
  headers: {
351
357
  "Content-Type": "application/octet-stream",
352
- "X-Session-Token": token,
358
+ ...authHeaders(token),
353
359
  "Vellum-Organization-Id": orgId,
354
360
  },
355
361
  body: new Blob([bundleData]),
@@ -375,7 +381,7 @@ export async function platformImportBundle(
375
381
  method: "POST",
376
382
  headers: {
377
383
  "Content-Type": "application/octet-stream",
378
- "X-Session-Token": token,
384
+ ...authHeaders(token),
379
385
  "Vellum-Organization-Id": orgId,
380
386
  },
381
387
  body: new Blob([bundleData]),
@@ -388,3 +394,131 @@ export async function platformImportBundle(
388
394
  >;
389
395
  return { statusCode: response.status, body };
390
396
  }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Signed-URL upload flow
400
+ // ---------------------------------------------------------------------------
401
+
402
+ export async function platformRequestUploadUrl(
403
+ token: string,
404
+ orgId: string,
405
+ platformUrl?: string,
406
+ ): Promise<{ uploadUrl: string; bundleKey: string; expiresAt: string }> {
407
+ const resolvedUrl = platformUrl || getPlatformUrl();
408
+ const response = await fetch(`${resolvedUrl}/v1/migrations/upload-url/`, {
409
+ method: "POST",
410
+ headers: {
411
+ "Content-Type": "application/json",
412
+ ...authHeaders(token),
413
+ "Vellum-Organization-Id": orgId,
414
+ },
415
+ body: JSON.stringify({ content_type: "application/octet-stream" }),
416
+ });
417
+
418
+ if (response.status === 201) {
419
+ const body = (await response.json()) as {
420
+ upload_url: string;
421
+ bundle_key: string;
422
+ expires_at: string;
423
+ };
424
+ return {
425
+ uploadUrl: body.upload_url,
426
+ bundleKey: body.bundle_key,
427
+ expiresAt: body.expires_at,
428
+ };
429
+ }
430
+
431
+ if (response.status === 404 || response.status === 503) {
432
+ throw new Error(
433
+ "Signed uploads are not available on this platform instance",
434
+ );
435
+ }
436
+
437
+ const errorBody = (await response.json().catch(() => ({}))) as {
438
+ detail?: string;
439
+ };
440
+ throw new Error(
441
+ errorBody.detail ??
442
+ `Failed to request upload URL: ${response.status} ${response.statusText}`,
443
+ );
444
+ }
445
+
446
+ export async function platformUploadToSignedUrl(
447
+ uploadUrl: string,
448
+ bundleData: Uint8Array<ArrayBuffer>,
449
+ ): Promise<void> {
450
+ const response = await fetch(uploadUrl, {
451
+ method: "PUT",
452
+ headers: {
453
+ "Content-Type": "application/octet-stream",
454
+ },
455
+ body: new Blob([bundleData]),
456
+ signal: AbortSignal.timeout(600_000),
457
+ });
458
+
459
+ if (!response.ok) {
460
+ throw new Error(
461
+ `Upload to signed URL failed: ${response.status} ${response.statusText}`,
462
+ );
463
+ }
464
+ }
465
+
466
+ export async function platformImportPreflightFromGcs(
467
+ bundleKey: string,
468
+ token: string,
469
+ orgId: string,
470
+ platformUrl?: string,
471
+ ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
472
+ const resolvedUrl = platformUrl || getPlatformUrl();
473
+ const response = await fetch(
474
+ `${resolvedUrl}/v1/migrations/import-preflight-from-gcs/`,
475
+ {
476
+ method: "POST",
477
+ headers: {
478
+ "Content-Type": "application/json",
479
+ ...authHeaders(token),
480
+ "Vellum-Organization-Id": orgId,
481
+ },
482
+ body: JSON.stringify({ bundle_key: bundleKey }),
483
+ signal: AbortSignal.timeout(120_000),
484
+ },
485
+ );
486
+
487
+ const body = (await response.json().catch(() => ({}))) as Record<
488
+ string,
489
+ unknown
490
+ >;
491
+ return { statusCode: response.status, body };
492
+ }
493
+
494
+ export async function platformImportBundleFromGcs(
495
+ bundleKey: string,
496
+ token: string,
497
+ orgId: string,
498
+ platformUrl?: string,
499
+ ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
500
+ const resolvedUrl = platformUrl || getPlatformUrl();
501
+ const response = await fetch(
502
+ `${resolvedUrl}/v1/migrations/import-from-gcs/`,
503
+ {
504
+ method: "POST",
505
+ headers: {
506
+ "Content-Type": "application/json",
507
+ ...authHeaders(token),
508
+ "Vellum-Organization-Id": orgId,
509
+ },
510
+ body: JSON.stringify({ bundle_key: bundleKey }),
511
+ signal: AbortSignal.timeout(120_000),
512
+ },
513
+ );
514
+
515
+ if (response.status === 413) {
516
+ throw new Error("Bundle too large to import");
517
+ }
518
+
519
+ const body = (await response.json().catch(() => ({}))) as Record<
520
+ string,
521
+ unknown
522
+ >;
523
+ return { statusCode: response.status, body };
524
+ }
@@ -90,6 +90,7 @@ export function buildUpgradeCommitMessage(options: {
90
90
  */
91
91
  export const CONTAINER_ENV_EXCLUDE_KEYS: ReadonlySet<string> = new Set([
92
92
  "CES_SERVICE_TOKEN",
93
+ "GUARDIAN_BOOTSTRAP_SECRET",
93
94
  "VELLUM_ASSISTANT_NAME",
94
95
  "RUNTIME_HTTP_HOST",
95
96
  "PATH",
@@ -467,6 +468,11 @@ export async function performDockerRollback(
467
468
  ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
468
469
  );
469
470
 
471
+ // Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
472
+ // set on gateway, not assistant) so it persists across container restarts.
473
+ const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
474
+ const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
475
+
470
476
  const cesServiceToken =
471
477
  capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
472
478
 
@@ -575,6 +581,7 @@ export async function performDockerRollback(
575
581
  await startContainers(
576
582
  {
577
583
  signingKey,
584
+ bootstrapSecret,
578
585
  cesServiceToken,
579
586
  extraAssistantEnv,
580
587
  gatewayPort,
@@ -695,6 +702,7 @@ export async function performDockerRollback(
695
702
  await startContainers(
696
703
  {
697
704
  signingKey,
705
+ bootstrapSecret,
698
706
  cesServiceToken,
699
707
  extraAssistantEnv,
700
708
  gatewayPort,