@vellumai/cli 0.5.3 → 0.5.5

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
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "crypto";
1
2
  import { chmodSync, existsSync, mkdirSync, watch as fsWatch } from "fs";
2
3
  import { arch, platform } from "os";
3
4
  import { dirname, join } from "path";
@@ -11,7 +12,7 @@ import {
11
12
  setActiveAssistant,
12
13
  } from "./assistant-config";
13
14
  import type { AssistantEntry } from "./assistant-config";
14
- import { DEFAULT_GATEWAY_PORT } from "./constants";
15
+ import { DEFAULT_GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
15
16
  import type { Species } from "./constants";
16
17
  import { leaseGuardianToken } from "./guardian-token";
17
18
  import { isVellumProcess, stopProcess } from "./process";
@@ -282,10 +283,14 @@ export function dockerResourceNames(instanceName: string) {
282
283
  return {
283
284
  assistantContainer: `${instanceName}-assistant`,
284
285
  cesContainer: `${instanceName}-credential-executor`,
286
+ cesSecurityVolume: `${instanceName}-ces-sec`,
287
+ /** @deprecated Legacy — no longer created for new instances. Retained for migration of existing instances. */
285
288
  dataVolume: `${instanceName}-data`,
286
289
  gatewayContainer: `${instanceName}-gateway`,
290
+ gatewaySecurityVolume: `${instanceName}-gateway-sec`,
287
291
  network: `${instanceName}-net`,
288
292
  socketVolume: `${instanceName}-socket`,
293
+ workspaceVolume: `${instanceName}-workspace`,
289
294
  };
290
295
  }
291
296
 
@@ -335,7 +340,13 @@ export async function retireDocker(name: string): Promise<void> {
335
340
  } catch {
336
341
  // network may not exist
337
342
  }
338
- for (const vol of [res.dataVolume, res.socketVolume]) {
343
+ for (const vol of [
344
+ res.dataVolume,
345
+ res.socketVolume,
346
+ res.workspaceVolume,
347
+ res.cesSecurityVolume,
348
+ res.gatewaySecurityVolume,
349
+ ]) {
339
350
  try {
340
351
  await exec("docker", ["volume", "rm", vol]);
341
352
  } catch {
@@ -453,13 +464,21 @@ async function buildAllImages(
453
464
  * can be restarted independently.
454
465
  */
455
466
  export function serviceDockerRunArgs(opts: {
467
+ cesServiceToken?: string;
456
468
  extraAssistantEnv?: Record<string, string>;
457
469
  gatewayPort: number;
458
470
  imageTags: Record<ServiceName, string>;
459
471
  instanceName: string;
460
472
  res: ReturnType<typeof dockerResourceNames>;
461
473
  }): Record<ServiceName, () => string[]> {
462
- const { extraAssistantEnv, gatewayPort, imageTags, instanceName, res } = opts;
474
+ const {
475
+ cesServiceToken,
476
+ extraAssistantEnv,
477
+ gatewayPort,
478
+ imageTags,
479
+ instanceName,
480
+ res,
481
+ } = opts;
463
482
  return {
464
483
  assistant: () => {
465
484
  const args: string[] = [
@@ -470,15 +489,27 @@ export function serviceDockerRunArgs(opts: {
470
489
  res.assistantContainer,
471
490
  `--network=${res.network}`,
472
491
  "-v",
473
- `${res.dataVolume}:/data`,
492
+ `${res.workspaceVolume}:/workspace`,
474
493
  "-v",
475
494
  `${res.socketVolume}:/run/ces-bootstrap`,
476
495
  "-e",
477
496
  `VELLUM_ASSISTANT_NAME=${instanceName}`,
478
497
  "-e",
479
498
  "RUNTIME_HTTP_HOST=0.0.0.0",
499
+ "-e",
500
+ "WORKSPACE_DIR=/workspace",
501
+ "-e",
502
+ `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
503
+ "-e",
504
+ `GATEWAY_INTERNAL_URL=http://${res.gatewayContainer}:${GATEWAY_INTERNAL_PORT}`,
480
505
  ];
481
- for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
506
+ if (cesServiceToken) {
507
+ args.push("-e", `CES_SERVICE_TOKEN=${cesServiceToken}`);
508
+ }
509
+ for (const envVar of [
510
+ ...Object.values(PROVIDER_ENV_VAR_NAMES),
511
+ "VELLUM_PLATFORM_URL",
512
+ ]) {
482
513
  if (process.env[envVar]) {
483
514
  args.push("-e", `${envVar}=${process.env[envVar]}`);
484
515
  }
@@ -501,9 +532,13 @@ export function serviceDockerRunArgs(opts: {
501
532
  "-p",
502
533
  `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
503
534
  "-v",
504
- `${res.dataVolume}:/data`,
535
+ `${res.workspaceVolume}:/workspace`,
536
+ "-v",
537
+ `${res.gatewaySecurityVolume}:/gateway-security`,
505
538
  "-e",
506
- "BASE_DATA_DIR=/data",
539
+ "WORKSPACE_DIR=/workspace",
540
+ "-e",
541
+ "GATEWAY_SECURITY_DIR=/gateway-security",
507
542
  "-e",
508
543
  `GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
509
544
  "-e",
@@ -512,6 +547,11 @@ export function serviceDockerRunArgs(opts: {
512
547
  `RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
513
548
  "-e",
514
549
  "RUNTIME_PROXY_ENABLED=true",
550
+ "-e",
551
+ `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
552
+ ...(cesServiceToken
553
+ ? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
554
+ : []),
515
555
  imageTags.gateway,
516
556
  ],
517
557
  "credential-executor": () => [
@@ -520,21 +560,171 @@ export function serviceDockerRunArgs(opts: {
520
560
  "-d",
521
561
  "--name",
522
562
  res.cesContainer,
563
+ `--network=${res.network}`,
523
564
  "-v",
524
565
  `${res.socketVolume}:/run/ces-bootstrap`,
525
566
  "-v",
526
- `${res.dataVolume}:/data:ro`,
567
+ `${res.workspaceVolume}:/workspace:ro`,
568
+ "-v",
569
+ `${res.cesSecurityVolume}:/ces-security`,
527
570
  "-e",
528
571
  "CES_MODE=managed",
529
572
  "-e",
573
+ "WORKSPACE_DIR=/workspace",
574
+ "-e",
530
575
  "CES_BOOTSTRAP_SOCKET_DIR=/run/ces-bootstrap",
531
576
  "-e",
532
- "CES_ASSISTANT_DATA_MOUNT=/data",
577
+ "CREDENTIAL_SECURITY_DIR=/ces-security",
578
+ ...(cesServiceToken
579
+ ? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
580
+ : []),
533
581
  imageTags["credential-executor"],
534
582
  ],
535
583
  };
536
584
  }
537
585
 
586
+ /**
587
+ * Check whether a Docker volume exists.
588
+ * Returns true if the volume exists, false otherwise.
589
+ */
590
+ async function dockerVolumeExists(volumeName: string): Promise<boolean> {
591
+ try {
592
+ await execOutput("docker", ["volume", "inspect", volumeName]);
593
+ return true;
594
+ } catch {
595
+ return false;
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Migrate trust.json and actor-token-signing-key from the data volume
601
+ * (old location: /data/.vellum/protected/) to the gateway security volume
602
+ * (new location: /gateway-security/).
603
+ *
604
+ * Uses a temporary busybox container that mounts both volumes. The migration
605
+ * is idempotent: it only copies a file when the source exists on the data
606
+ * volume and the destination does not yet exist on the gateway security volume.
607
+ *
608
+ * Skips migration entirely if the data volume does not exist (new instances
609
+ * no longer create one).
610
+ */
611
+ export async function migrateGatewaySecurityFiles(
612
+ res: ReturnType<typeof dockerResourceNames>,
613
+ log: (msg: string) => void,
614
+ ): Promise<void> {
615
+ // New instances don't have a data volume — nothing to migrate.
616
+ if (!(await dockerVolumeExists(res.dataVolume))) {
617
+ log(" No data volume found — skipping gateway security migration.");
618
+ return;
619
+ }
620
+
621
+ const migrationContainer = `${res.gatewayContainer}-migration`;
622
+ const filesToMigrate = ["trust.json", "actor-token-signing-key"];
623
+
624
+ // Remove any leftover migration container from a previous interrupted run.
625
+ try {
626
+ await exec("docker", ["rm", "-f", migrationContainer]);
627
+ } catch {
628
+ // container may not exist
629
+ }
630
+
631
+ for (const fileName of filesToMigrate) {
632
+ const src = `/data/.vellum/protected/${fileName}`;
633
+ const dst = `/gateway-security/${fileName}`;
634
+
635
+ try {
636
+ // Run a busybox container that checks source exists and destination
637
+ // does not, then copies. The shell exits 0 whether or not a copy
638
+ // happens, so the migration is always safe to re-run.
639
+ await exec("docker", [
640
+ "run",
641
+ "--rm",
642
+ "--name",
643
+ migrationContainer,
644
+ "-v",
645
+ `${res.dataVolume}:/data:ro`,
646
+ "-v",
647
+ `${res.gatewaySecurityVolume}:/gateway-security`,
648
+ "busybox",
649
+ "sh",
650
+ "-c",
651
+ `if [ -f "${src}" ] && [ ! -f "${dst}" ]; then cp "${src}" "${dst}" && echo "migrated"; else echo "skipped"; fi`,
652
+ ]);
653
+ log(` ${fileName}: checked`);
654
+ } catch (err) {
655
+ // Non-fatal — log and continue. The gateway will create fresh files
656
+ // if they don't exist.
657
+ const message = err instanceof Error ? err.message : String(err);
658
+ log(` ${fileName}: migration failed (${message}), continuing...`);
659
+ }
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Migrate keys.enc and store.key from the data volume
665
+ * (old location: /data/.vellum/protected/) to the CES security volume
666
+ * (new location: /ces-security/).
667
+ *
668
+ * Uses a temporary busybox container that mounts both volumes. The migration
669
+ * is idempotent: it only copies a file when the source exists on the data
670
+ * volume and the destination does not yet exist on the CES security volume.
671
+ * Migrated files are chowned to 1001:1001 (the CES service user).
672
+ *
673
+ * Skips migration entirely if the data volume does not exist (new instances
674
+ * no longer create one).
675
+ */
676
+ export async function migrateCesSecurityFiles(
677
+ res: ReturnType<typeof dockerResourceNames>,
678
+ log: (msg: string) => void,
679
+ ): Promise<void> {
680
+ // New instances don't have a data volume — nothing to migrate.
681
+ if (!(await dockerVolumeExists(res.dataVolume))) {
682
+ log(" No data volume found — skipping CES security migration.");
683
+ return;
684
+ }
685
+
686
+ const migrationContainer = `${res.cesContainer}-migration`;
687
+ const filesToMigrate = ["keys.enc", "store.key"];
688
+
689
+ // Remove any leftover migration container from a previous interrupted run.
690
+ try {
691
+ await exec("docker", ["rm", "-f", migrationContainer]);
692
+ } catch {
693
+ // container may not exist
694
+ }
695
+
696
+ for (const fileName of filesToMigrate) {
697
+ const src = `/data/.vellum/protected/${fileName}`;
698
+ const dst = `/ces-security/${fileName}`;
699
+
700
+ try {
701
+ // Run a busybox container that checks source exists and destination
702
+ // does not, then copies and sets ownership. The shell exits 0 whether
703
+ // or not a copy happens, so the migration is always safe to re-run.
704
+ await exec("docker", [
705
+ "run",
706
+ "--rm",
707
+ "--name",
708
+ migrationContainer,
709
+ "-v",
710
+ `${res.dataVolume}:/data:ro`,
711
+ "-v",
712
+ `${res.cesSecurityVolume}:/ces-security`,
713
+ "busybox",
714
+ "sh",
715
+ "-c",
716
+ `if [ -f "${src}" ] && [ ! -f "${dst}" ]; then cp "${src}" "${dst}" && chown 1001:1001 "${dst}" && echo "migrated"; else echo "skipped"; fi`,
717
+ ]);
718
+ log(` ${fileName}: checked`);
719
+ } catch (err) {
720
+ // Non-fatal — log and continue. The CES will start without
721
+ // credentials if they don't exist.
722
+ const message = err instanceof Error ? err.message : String(err);
723
+ log(` ${fileName}: migration failed (${message}), continuing...`);
724
+ }
725
+ }
726
+ }
727
+
538
728
  /** The order in which services must be started. */
539
729
  export const SERVICE_START_ORDER: ServiceName[] = [
540
730
  "assistant",
@@ -545,6 +735,7 @@ export const SERVICE_START_ORDER: ServiceName[] = [
545
735
  /** Start all three containers in dependency order. */
546
736
  export async function startContainers(
547
737
  opts: {
738
+ cesServiceToken?: string;
548
739
  extraAssistantEnv?: Record<string, string>;
549
740
  gatewayPort: number;
550
741
  imageTags: Record<ServiceName, string>;
@@ -569,6 +760,36 @@ export async function stopContainers(
569
760
  await removeContainer(res.assistantContainer);
570
761
  }
571
762
 
763
+ /** Stop containers without removing them (preserves state for `docker start`). */
764
+ export async function sleepContainers(
765
+ res: ReturnType<typeof dockerResourceNames>,
766
+ ): Promise<void> {
767
+ for (const container of [
768
+ res.cesContainer,
769
+ res.gatewayContainer,
770
+ res.assistantContainer,
771
+ ]) {
772
+ try {
773
+ await exec("docker", ["stop", container]);
774
+ } catch {
775
+ // container may not exist or already stopped
776
+ }
777
+ }
778
+ }
779
+
780
+ /** Start existing stopped containers. */
781
+ export async function wakeContainers(
782
+ res: ReturnType<typeof dockerResourceNames>,
783
+ ): Promise<void> {
784
+ for (const container of [
785
+ res.assistantContainer,
786
+ res.gatewayContainer,
787
+ res.cesContainer,
788
+ ]) {
789
+ await exec("docker", ["start", container]);
790
+ }
791
+ }
792
+
572
793
  /**
573
794
  * Capture the current image references for running service containers.
574
795
  * Returns a complete record of service → immutable image ID (sha256 digest)
@@ -845,10 +1066,18 @@ export async function hatchDocker(
845
1066
 
846
1067
  log("📁 Creating network and volumes...");
847
1068
  await exec("docker", ["network", "create", res.network]);
848
- await exec("docker", ["volume", "create", res.dataVolume]);
849
1069
  await exec("docker", ["volume", "create", res.socketVolume]);
1070
+ await exec("docker", ["volume", "create", res.workspaceVolume]);
1071
+ await exec("docker", ["volume", "create", res.cesSecurityVolume]);
1072
+ await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
1073
+
1074
+ const cesServiceToken = randomBytes(32).toString("hex");
1075
+ await startContainers(
1076
+ { cesServiceToken, gatewayPort, imageTags, instanceName, res },
1077
+ log,
1078
+ );
850
1079
 
851
- await startContainers({ gatewayPort, imageTags, instanceName, res }, log);
1080
+ const imageDigests = await captureImageRefs(res);
852
1081
 
853
1082
  const runtimeUrl = `http://localhost:${gatewayPort}`;
854
1083
  const dockerEntry: AssistantEntry = {
@@ -858,6 +1087,16 @@ export async function hatchDocker(
858
1087
  species,
859
1088
  hatchedAt: new Date().toISOString(),
860
1089
  volume: res.dataVolume,
1090
+ serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
1091
+ containerInfo: {
1092
+ assistantImage: imageTags.assistant,
1093
+ gatewayImage: imageTags.gateway,
1094
+ cesImage: imageTags["credential-executor"],
1095
+ assistantDigest: imageDigests?.assistant,
1096
+ gatewayDigest: imageDigests?.gateway,
1097
+ cesDigest: imageDigests?.["credential-executor"],
1098
+ networkName: res.network,
1099
+ },
861
1100
  };
862
1101
  saveAssistantEntry(dockerEntry);
863
1102
  setActiveAssistant(instanceName);
package/src/lib/gcp.ts CHANGED
@@ -4,7 +4,11 @@ import { join } from "path";
4
4
 
5
5
  import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
6
6
  import type { AssistantEntry } from "./assistant-config";
7
- import { FIREWALL_TAG, GATEWAY_PORT } from "./constants";
7
+ import {
8
+ FIREWALL_TAG,
9
+ GATEWAY_PORT,
10
+ PROVIDER_ENV_VAR_NAMES,
11
+ } from "./constants";
8
12
  import type { Species } from "./constants";
9
13
  import { leaseGuardianToken } from "./guardian-token";
10
14
  import { generateInstanceName } from "./random-name";
@@ -448,7 +452,7 @@ export async function hatchGcp(
448
452
  buildStartupScript: (
449
453
  species: Species,
450
454
  sshUser: string,
451
- anthropicApiKey: string,
455
+ providerApiKeys: Record<string, string>,
452
456
  instanceName: string,
453
457
  cloud: "gcp",
454
458
  ) => Promise<string>,
@@ -500,17 +504,25 @@ export async function hatchGcp(
500
504
 
501
505
  const sshUser = userInfo().username;
502
506
  const hatchedBy = process.env.VELLUM_HATCHED_BY;
503
- const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
504
- if (!anthropicApiKey) {
507
+ const providerApiKeys: Record<string, string> = {};
508
+ for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
509
+ const value = process.env[envVar];
510
+ if (value) {
511
+ providerApiKeys[envVar] = value;
512
+ }
513
+ }
514
+ if (Object.keys(providerApiKeys).length === 0) {
505
515
  console.error(
506
- "Error: ANTHROPIC_API_KEY environment variable is not set.",
516
+ "Error: No provider API key environment variable is set. " +
517
+ "Set at least one of: " +
518
+ Object.values(PROVIDER_ENV_VAR_NAMES).join(", "),
507
519
  );
508
520
  process.exit(1);
509
521
  }
510
522
  const startupScript = await buildStartupScript(
511
523
  species,
512
524
  sshUser,
513
- anthropicApiKey,
525
+ providerApiKeys,
514
526
  instanceName,
515
527
  "gcp",
516
528
  );