@vellumai/cli 0.5.13 → 0.5.14

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.
@@ -13,8 +13,6 @@ import {
13
13
  captureImageRefs,
14
14
  GATEWAY_INTERNAL_PORT,
15
15
  dockerResourceNames,
16
- migrateCesSecurityFiles,
17
- migrateGatewaySecurityFiles,
18
16
  startContainers,
19
17
  stopContainers,
20
18
  } from "../lib/docker";
@@ -417,12 +415,6 @@ async function upgradeDocker(
417
415
  }
418
416
  }
419
417
 
420
- console.log("🔄 Migrating security files to gateway volume...");
421
- await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
422
-
423
- console.log("🔄 Migrating credential files to CES security volume...");
424
- await migrateCesSecurityFiles(res, (msg) => console.log(msg));
425
-
426
418
  console.log("🚀 Starting upgraded containers...");
427
419
  await startContainers(
428
420
  {
@@ -522,9 +514,6 @@ async function upgradeDocker(
522
514
 
523
515
  await stopContainers(res);
524
516
 
525
- await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
526
- await migrateCesSecurityFiles(res, (msg) => console.log(msg));
527
-
528
517
  await startContainers(
529
518
  {
530
519
  signingKey,
@@ -1,10 +1,14 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
- import { resolveTargetAssistant } from "../lib/assistant-config.js";
4
+ import {
5
+ resolveTargetAssistant,
6
+ saveAssistantEntry,
7
+ } from "../lib/assistant-config.js";
5
8
  import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
6
9
  import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
7
10
  import {
11
+ generateLocalSigningKey,
8
12
  isAssistantWatchModeAvailable,
9
13
  isGatewayWatchModeAvailable,
10
14
  startLocalDaemon,
@@ -106,8 +110,17 @@ export async function wake(): Promise<void> {
106
110
  }
107
111
  }
108
112
 
113
+ // Reuse the lockfile-persisted signing key so client actor tokens survive
114
+ // daemon/gateway restarts. Generate and persist a new one only on first wake.
115
+ let signingKey = resources.signingKey;
116
+ if (!signingKey) {
117
+ signingKey = generateLocalSigningKey();
118
+ entry.resources = { ...resources, signingKey };
119
+ saveAssistantEntry(entry);
120
+ }
121
+
109
122
  if (!daemonRunning) {
110
- await startLocalDaemon(watch, resources, { foreground });
123
+ await startLocalDaemon(watch, resources, { foreground, signingKey });
111
124
  }
112
125
 
113
126
  // Start gateway
@@ -127,13 +140,13 @@ export async function wake(): Promise<void> {
127
140
  `Gateway running (pid ${pid}) — restarting in watch mode...`,
128
141
  );
129
142
  await stopProcessByPidFile(gatewayPidFile, "gateway");
130
- await startGateway(watch, resources);
143
+ await startGateway(watch, resources, { signingKey });
131
144
  }
132
145
  } else {
133
146
  console.log(`Gateway already running (pid ${pid}).`);
134
147
  }
135
148
  } else {
136
- await startGateway(watch, resources);
149
+ await startGateway(watch, resources, { signingKey });
137
150
  }
138
151
  }
139
152
 
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { rollback } from "./commands/rollback";
15
15
  import { setup } from "./commands/setup";
16
16
  import { sleep } from "./commands/sleep";
17
17
  import { ssh } from "./commands/ssh";
18
+ import { teleport } from "./commands/teleport";
18
19
  import { tunnel } from "./commands/tunnel";
19
20
  import { upgrade } from "./commands/upgrade";
20
21
  import { use } from "./commands/use";
@@ -44,6 +45,7 @@ const commands = {
44
45
  setup,
45
46
  sleep,
46
47
  ssh,
48
+ teleport,
47
49
  tunnel,
48
50
  upgrade,
49
51
  use,
@@ -76,6 +78,7 @@ function printHelp(): void {
76
78
  console.log(" setup Configure API keys interactively");
77
79
  console.log(" sleep Stop the assistant process");
78
80
  console.log(" ssh SSH into a remote assistant instance");
81
+ console.log(" teleport Transfer assistant data between environments");
79
82
  console.log(" tunnel Create a tunnel for a locally hosted assistant");
80
83
  console.log(" upgrade Upgrade an assistant to a newer version");
81
84
  console.log(" use Set the active assistant for commands");
@@ -43,6 +43,9 @@ export interface LocalInstanceResources {
43
43
  cesPort: number;
44
44
  /** Absolute path to the daemon PID file */
45
45
  pidFile: string;
46
+ /** Persisted HMAC signing key (hex). Survives daemon/gateway restarts so
47
+ * client actor tokens remain valid across `wake` cycles. */
48
+ signingKey?: string;
46
49
  [key: string]: unknown;
47
50
  }
48
51
 
@@ -77,8 +80,6 @@ export interface AssistantEntry {
77
80
  sshUser?: string;
78
81
  zone?: string;
79
82
  hatchedAt?: string;
80
- /** Name of the shared volume backing BASE_DATA_DIR for containerised instances. */
81
- volume?: string;
82
83
  /** Per-instance resource config. Present for local entries in multi-instance setups. */
83
84
  resources?: LocalInstanceResources;
84
85
  /** PID of the file watcher process for docker instances hatched with --watch. */
package/src/lib/docker.ts CHANGED
@@ -306,8 +306,6 @@ export function dockerResourceNames(instanceName: string) {
306
306
  assistantContainer: `${instanceName}-assistant`,
307
307
  cesContainer: `${instanceName}-credential-executor`,
308
308
  cesSecurityVolume: `${instanceName}-ces-sec`,
309
- /** @deprecated Legacy — no longer created for new instances. Retained for migration of existing instances. */
310
- dataVolume: `${instanceName}-data`,
311
309
  gatewayContainer: `${instanceName}-gateway`,
312
310
  gatewaySecurityVolume: `${instanceName}-gateway-sec`,
313
311
  network: `${instanceName}-net`,
@@ -363,7 +361,6 @@ export async function retireDocker(name: string): Promise<void> {
363
361
  // network may not exist
364
362
  }
365
363
  for (const vol of [
366
- res.dataVolume,
367
364
  res.socketVolume,
368
365
  res.workspaceVolume,
369
366
  res.cesSecurityVolume,
@@ -519,6 +516,8 @@ export function serviceDockerRunArgs(opts: {
519
516
  "-v",
520
517
  `${res.socketVolume}:/run/ces-bootstrap`,
521
518
  "-e",
519
+ "IS_CONTAINERIZED=false",
520
+ "-e",
522
521
  `VELLUM_ASSISTANT_NAME=${instanceName}`,
523
522
  "-e",
524
523
  "VELLUM_CLOUD=docker",
@@ -629,148 +628,6 @@ export function serviceDockerRunArgs(opts: {
629
628
  };
630
629
  }
631
630
 
632
- /**
633
- * Check whether a Docker volume exists.
634
- * Returns true if the volume exists, false otherwise.
635
- */
636
- async function dockerVolumeExists(volumeName: string): Promise<boolean> {
637
- try {
638
- await execOutput("docker", ["volume", "inspect", volumeName]);
639
- return true;
640
- } catch {
641
- return false;
642
- }
643
- }
644
-
645
- /**
646
- * Migrate trust.json and actor-token-signing-key from the data volume
647
- * (old location: /data/.vellum/protected/) to the gateway security volume
648
- * (new location: /gateway-security/).
649
- *
650
- * Uses a temporary busybox container that mounts both volumes. The migration
651
- * is idempotent: it only copies a file when the source exists on the data
652
- * volume and the destination does not yet exist on the gateway security volume.
653
- *
654
- * Skips migration entirely if the data volume does not exist (new instances
655
- * no longer create one).
656
- */
657
- export async function migrateGatewaySecurityFiles(
658
- res: ReturnType<typeof dockerResourceNames>,
659
- log: (msg: string) => void,
660
- ): Promise<void> {
661
- // New instances don't have a data volume — nothing to migrate.
662
- if (!(await dockerVolumeExists(res.dataVolume))) {
663
- log(" No data volume found — skipping gateway security migration.");
664
- return;
665
- }
666
-
667
- const migrationContainer = `${res.gatewayContainer}-migration`;
668
- const filesToMigrate = ["trust.json", "actor-token-signing-key"];
669
-
670
- // Remove any leftover migration container from a previous interrupted run.
671
- try {
672
- await exec("docker", ["rm", "-f", migrationContainer]);
673
- } catch {
674
- // container may not exist
675
- }
676
-
677
- for (const fileName of filesToMigrate) {
678
- const src = `/data/.vellum/protected/${fileName}`;
679
- const dst = `/gateway-security/${fileName}`;
680
-
681
- try {
682
- // Run a busybox container that checks source exists and destination
683
- // does not, then copies. The shell exits 0 whether or not a copy
684
- // happens, so the migration is always safe to re-run.
685
- await exec("docker", [
686
- "run",
687
- "--rm",
688
- "--name",
689
- migrationContainer,
690
- "-v",
691
- `${res.dataVolume}:/data:ro`,
692
- "-v",
693
- `${res.gatewaySecurityVolume}:/gateway-security`,
694
- "busybox",
695
- "sh",
696
- "-c",
697
- `if [ -f "${src}" ] && [ ! -f "${dst}" ]; then cp "${src}" "${dst}" && echo "migrated"; else echo "skipped"; fi`,
698
- ]);
699
- log(` ${fileName}: checked`);
700
- } catch (err) {
701
- // Non-fatal — log and continue. The gateway will create fresh files
702
- // if they don't exist.
703
- const message = err instanceof Error ? err.message : String(err);
704
- log(` ${fileName}: migration failed (${message}), continuing...`);
705
- }
706
- }
707
- }
708
-
709
- /**
710
- * Migrate keys.enc and store.key from the data volume
711
- * (old location: /data/.vellum/protected/) to the CES security volume
712
- * (new location: /ces-security/).
713
- *
714
- * Uses a temporary busybox container that mounts both volumes. The migration
715
- * is idempotent: it only copies a file when the source exists on the data
716
- * volume and the destination does not yet exist on the CES security volume.
717
- * Migrated files are chowned to 1001:1001 (the CES service user).
718
- *
719
- * Skips migration entirely if the data volume does not exist (new instances
720
- * no longer create one).
721
- */
722
- export async function migrateCesSecurityFiles(
723
- res: ReturnType<typeof dockerResourceNames>,
724
- log: (msg: string) => void,
725
- ): Promise<void> {
726
- // New instances don't have a data volume — nothing to migrate.
727
- if (!(await dockerVolumeExists(res.dataVolume))) {
728
- log(" No data volume found — skipping CES security migration.");
729
- return;
730
- }
731
-
732
- const migrationContainer = `${res.cesContainer}-migration`;
733
- const filesToMigrate = ["keys.enc", "store.key"];
734
-
735
- // Remove any leftover migration container from a previous interrupted run.
736
- try {
737
- await exec("docker", ["rm", "-f", migrationContainer]);
738
- } catch {
739
- // container may not exist
740
- }
741
-
742
- for (const fileName of filesToMigrate) {
743
- const src = `/data/.vellum/protected/${fileName}`;
744
- const dst = `/ces-security/${fileName}`;
745
-
746
- try {
747
- // Run a busybox container that checks source exists and destination
748
- // does not, then copies and sets ownership. The shell exits 0 whether
749
- // or not a copy happens, so the migration is always safe to re-run.
750
- await exec("docker", [
751
- "run",
752
- "--rm",
753
- "--name",
754
- migrationContainer,
755
- "-v",
756
- `${res.dataVolume}:/data:ro`,
757
- "-v",
758
- `${res.cesSecurityVolume}:/ces-security`,
759
- "busybox",
760
- "sh",
761
- "-c",
762
- `if [ -f "${src}" ] && [ ! -f "${dst}" ]; then cp "${src}" "${dst}" && chown 1001:1001 "${dst}" && echo "migrated"; else echo "skipped"; fi`,
763
- ]);
764
- log(` ${fileName}: checked`);
765
- } catch (err) {
766
- // Non-fatal — log and continue. The CES will start without
767
- // credentials if they don't exist.
768
- const message = err instanceof Error ? err.message : String(err);
769
- log(` ${fileName}: migration failed (${message}), continuing...`);
770
- }
771
- }
772
- }
773
-
774
631
  /** The order in which services must be started. */
775
632
  export const SERVICE_START_ORDER: ServiceName[] = [
776
633
  "assistant",
@@ -1202,7 +1059,6 @@ export async function hatchDocker(
1202
1059
  cloud: "docker",
1203
1060
  species,
1204
1061
  hatchedAt: new Date().toISOString(),
1205
- volume: res.dataVolume,
1206
1062
  serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
1207
1063
  containerInfo: {
1208
1064
  assistantImage: imageTags.assistant,
package/src/lib/local.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync, execSync, spawn } from "child_process";
2
+ import { randomBytes } from "crypto";
2
3
  import {
3
4
  existsSync,
4
5
  mkdirSync,
@@ -192,9 +193,24 @@ function resolveDaemonMainPath(assistantIndex: string): string {
192
193
  return join(dirname(assistantIndex), "daemon", "main.ts");
193
194
  }
194
195
 
196
+ /**
197
+ * Generate a fresh signing key for a local hatch session.
198
+ *
199
+ * Both the daemon and gateway must use the same HMAC signing key so JWT
200
+ * tokens minted by one can be verified by the other. The CLI generates
201
+ * an ephemeral key each time and passes it as `ACTOR_TOKEN_SIGNING_KEY`
202
+ * to both processes — the daemon and gateway each persist it on their
203
+ * own terms (the `.vellum/` directory layout is their concern, not the
204
+ * CLI's).
205
+ */
206
+ export function generateLocalSigningKey(): string {
207
+ return randomBytes(32).toString("hex");
208
+ }
209
+
195
210
  type DaemonStartOptions = {
196
211
  foreground?: boolean;
197
212
  defaultWorkspaceConfigPath?: string;
213
+ signingKey?: string;
198
214
  };
199
215
 
200
216
  async function startDaemonFromSource(
@@ -267,6 +283,9 @@ async function startDaemonFromSource(
267
283
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
268
284
  VELLUM_CLOUD: "local",
269
285
  VELLUM_DEV: "1",
286
+ ...(options?.signingKey
287
+ ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
288
+ : {}),
270
289
  };
271
290
  if (resources) {
272
291
  env.BASE_DATA_DIR = resources.instanceDir;
@@ -385,6 +404,9 @@ async function startDaemonWatchFromSource(
385
404
  ...process.env,
386
405
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
387
406
  VELLUM_DEV: "1",
407
+ ...(options?.signingKey
408
+ ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
409
+ : {}),
388
410
  };
389
411
  if (resources) {
390
412
  env.BASE_DATA_DIR = resources.instanceDir;
@@ -970,6 +992,10 @@ export async function startLocalDaemon(
970
992
  delete daemonEnv.QDRANT_URL;
971
993
  }
972
994
 
995
+ if (options?.signingKey) {
996
+ daemonEnv.ACTOR_TOKEN_SIGNING_KEY = options.signingKey;
997
+ }
998
+
973
999
  // Write a sentinel PID file before spawning so concurrent hatch() calls
974
1000
  // see the file and fall through to the isDaemonResponsive() port check
975
1001
  // instead of racing to spawn a duplicate daemon.
@@ -1081,6 +1107,7 @@ export async function startLocalDaemon(
1081
1107
  export async function startGateway(
1082
1108
  watch: boolean = false,
1083
1109
  resources?: LocalInstanceResources,
1110
+ options?: { signingKey?: string },
1084
1111
  ): Promise<string> {
1085
1112
  const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
1086
1113
 
@@ -1117,9 +1144,12 @@ export async function startGateway(
1117
1144
  ...(process.env as Record<string, string>),
1118
1145
  RUNTIME_HTTP_PORT: String(effectiveDaemonPort),
1119
1146
  GATEWAY_PORT: String(effectiveGatewayPort),
1147
+ ...(options?.signingKey
1148
+ ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
1149
+ : {}),
1120
1150
  ...(watch ? { VELLUM_DEV: "1" } : {}),
1121
- // Set BASE_DATA_DIR so the gateway loads the correct signing key and
1122
- // credentials for this instance (mirrors the daemon env setup).
1151
+ // Set BASE_DATA_DIR so the gateway loads the correct credentials and
1152
+ // workspace config for this instance (mirrors the daemon env setup).
1123
1153
  ...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
1124
1154
  };
1125
1155
  // The gateway reads the ingress URL from the workspace config file via
@@ -9,8 +9,6 @@ import {
9
9
  DOCKER_READY_TIMEOUT_MS,
10
10
  dockerResourceNames,
11
11
  GATEWAY_INTERNAL_PORT,
12
- migrateCesSecurityFiles,
13
- migrateGatewaySecurityFiles,
14
12
  startContainers,
15
13
  stopContainers,
16
14
  } from "./docker.js";
@@ -573,12 +571,6 @@ export async function performDockerRollback(
573
571
  await stopContainers(res);
574
572
  console.log("✅ Containers stopped\n");
575
573
 
576
- console.log("🔄 Migrating security files to gateway volume...");
577
- await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
578
-
579
- console.log("🔄 Migrating credential files to CES security volume...");
580
- await migrateCesSecurityFiles(res, (msg) => console.log(msg));
581
-
582
574
  console.log("🚀 Starting containers with target version...");
583
575
  await startContainers(
584
576
  {
@@ -700,9 +692,6 @@ export async function performDockerRollback(
700
692
 
701
693
  await stopContainers(res);
702
694
 
703
- await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
704
- await migrateCesSecurityFiles(res, (msg) => console.log(msg));
705
-
706
695
  await startContainers(
707
696
  {
708
697
  signingKey,