@vellumai/cli 0.5.4 → 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/AGENTS.md CHANGED
@@ -40,3 +40,15 @@ Every command must have high-quality `--help` output. Follow the same standards
40
40
  3. **Write for machines**: Be precise about formats, constraints, and side effects.
41
41
  AI agents parse help text to decide which command to run and how. Avoid vague
42
42
  language — say exactly what the command does and where state is stored.
43
+
44
+ ## Docker Volume Management
45
+
46
+ The CLI creates and manages Docker volumes for containerized instances. See the root `AGENTS.md` § Docker Volume Architecture for the full volume layout.
47
+
48
+ **Volume creation** (`hatch`): Creates four volumes per instance — workspace, gateway-security, ces-security, and socket. The legacy data volume is no longer created.
49
+
50
+ **Volume migration** (`wake`/`hatch`): On startup, existing instances that still have a legacy data volume are migrated. `migrateGatewaySecurityFiles()` and `migrateCesSecurityFiles()` in `lib/docker.ts` copy security files from the data volume to their respective security volumes. Migrations are idempotent and non-fatal.
51
+
52
+ **Volume cleanup** (`retire`): All volumes (including the legacy data volume if it exists) are removed when an instance is retired.
53
+
54
+ **Volume mount rules**: Each service container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -238,6 +238,7 @@ describe("migrateLegacyEntry", () => {
238
238
  expect(resources.daemonPort).toBe(7821);
239
239
  expect(resources.gatewayPort).toBe(7830);
240
240
  expect(resources.qdrantPort).toBe(6333);
241
+ expect(resources.cesPort).toBe(8090);
241
242
  expect(resources.pidFile).toContain("vellum.pid");
242
243
  });
243
244
 
@@ -310,6 +311,7 @@ describe("migrateLegacyEntry", () => {
310
311
  expect(resources.daemonPort).toBe(7821);
311
312
  expect(resources.gatewayPort).toBe(7830);
312
313
  expect(resources.qdrantPort).toBe(6333);
314
+ expect(resources.cesPort).toBe(8090);
313
315
  expect(resources.pidFile).toBe("/custom/path/.vellum/vellum.pid");
314
316
  });
315
317
 
@@ -328,6 +330,7 @@ describe("migrateLegacyEntry", () => {
328
330
  daemonPort: 8000,
329
331
  gatewayPort: 8001,
330
332
  qdrantPort: 8002,
333
+ cesPort: 8003,
331
334
  pidFile: "/my/path/.vellum/vellum.pid",
332
335
  },
333
336
  };
@@ -343,6 +346,7 @@ describe("migrateLegacyEntry", () => {
343
346
  expect(resources.daemonPort).toBe(8000);
344
347
  expect(resources.gatewayPort).toBe(8001);
345
348
  expect(resources.qdrantPort).toBe(8002);
349
+ expect(resources.cesPort).toBe(8003);
346
350
  });
347
351
 
348
352
  test("baseDataDir does not overwrite existing resources.instanceDir", () => {
@@ -377,6 +381,48 @@ describe("migrateLegacyEntry", () => {
377
381
  const resources = entry.resources as Record<string, unknown>;
378
382
  expect(resources.instanceDir).toBe("/new/path");
379
383
  });
384
+
385
+ test("backfills cesPort with default 8090", () => {
386
+ // GIVEN a local entry with resources that has no cesPort
387
+ const entry: Record<string, unknown> = {
388
+ assistantId: "no-ces-port",
389
+ runtimeUrl: "http://localhost:7830",
390
+ cloud: "local",
391
+ resources: {
392
+ instanceDir: "/custom/path",
393
+ daemonPort: 7821,
394
+ gatewayPort: 7830,
395
+ qdrantPort: 6333,
396
+ pidFile: "/custom/path/.vellum/vellum.pid",
397
+ },
398
+ };
399
+ // WHEN we migrate the entry
400
+ const changed = migrateLegacyEntry(entry);
401
+ // THEN cesPort should be backfilled
402
+ expect(changed).toBe(true);
403
+ const resources = entry.resources as Record<string, unknown>;
404
+ expect(resources.cesPort).toBe(8090);
405
+ });
406
+
407
+ test("does not overwrite existing cesPort", () => {
408
+ const entry: Record<string, unknown> = {
409
+ assistantId: "has-ces-port",
410
+ runtimeUrl: "http://localhost:7830",
411
+ cloud: "local",
412
+ resources: {
413
+ instanceDir: "/my/path",
414
+ daemonPort: 8000,
415
+ gatewayPort: 8001,
416
+ qdrantPort: 8002,
417
+ cesPort: 9090,
418
+ pidFile: "/my/path/.vellum/vellum.pid",
419
+ },
420
+ };
421
+ const changed = migrateLegacyEntry(entry);
422
+ expect(changed).toBe(false);
423
+ const resources = entry.resources as Record<string, unknown>;
424
+ expect(resources.cesPort).toBe(9090);
425
+ });
380
426
  });
381
427
 
382
428
  describe("legacy migration via loadAllAssistants", () => {
@@ -3,7 +3,7 @@ import { buildOpenclawRuntimeServer } from "../lib/openclaw-runtime-server";
3
3
 
4
4
  export async function buildOpenclawStartupScript(
5
5
  sshUser: string,
6
- anthropicApiKey: string,
6
+ providerApiKeys: Record<string, string>,
7
7
  timestampRedirect: string,
8
8
  userSetup: string,
9
9
  ownershipFixup: string,
@@ -110,7 +110,9 @@ echo -n "\$OPENCLAW_GW_TOKEN" > /tmp/openclaw-gateway-token
110
110
  chmod 600 /tmp/openclaw-gateway-token
111
111
 
112
112
  mkdir -p /root/.openclaw
113
- openclaw config set env.ANTHROPIC_API_KEY "${anthropicApiKey}"
113
+ ${Object.entries(providerApiKeys)
114
+ .map(([envVar, value]) => `openclaw config set env.${envVar} "${value}"`)
115
+ .join("\n")}
114
116
  openclaw config set agents.defaults.model.primary "anthropic/claude-opus-4-6"
115
117
  openclaw config set gateway.auth.token "\$OPENCLAW_GW_TOKEN"
116
118
 
@@ -0,0 +1,151 @@
1
+ import { mkdirSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, join } from "path";
4
+
5
+ import { findAssistantByName } from "../lib/assistant-config";
6
+ import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
7
+
8
+ function getBackupsDir(): string {
9
+ const dataHome =
10
+ process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
11
+ return join(dataHome, "vellum", "backups");
12
+ }
13
+
14
+ function formatSize(bytes: number): string {
15
+ if (bytes < 1024) return `${bytes} B`;
16
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
17
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
18
+ }
19
+
20
+ export async function backup(): Promise<void> {
21
+ const args = process.argv.slice(3);
22
+
23
+ if (args.includes("--help") || args.includes("-h")) {
24
+ console.log("Usage: vellum backup <name> [--output <path>]");
25
+ console.log("");
26
+ console.log(
27
+ "Export a backup of a running assistant as a .vbundle archive.",
28
+ );
29
+ console.log("");
30
+ console.log("Arguments:");
31
+ console.log(" <name> Name of the assistant to back up");
32
+ console.log("");
33
+ console.log("Options:");
34
+ console.log(" --output <path> Path to save the .vbundle file");
35
+ console.log(
36
+ " (default: ~/.local/share/vellum/backups/<name>-<timestamp>.vbundle)",
37
+ );
38
+ console.log("");
39
+ console.log("Examples:");
40
+ console.log(" vellum backup my-assistant");
41
+ console.log(
42
+ " vellum backup my-assistant --output ~/Desktop/backup.vbundle",
43
+ );
44
+ process.exit(0);
45
+ }
46
+
47
+ const name = args[0];
48
+ if (!name || name.startsWith("-")) {
49
+ console.error("Usage: vellum backup <name> [--output <path>]");
50
+ process.exit(1);
51
+ }
52
+
53
+ // Parse --output flag
54
+ let outputArg: string | undefined;
55
+ for (let i = 1; i < args.length; i++) {
56
+ if (args[i] === "--output" && args[i + 1]) {
57
+ outputArg = args[i + 1];
58
+ break;
59
+ }
60
+ }
61
+
62
+ // Look up the instance
63
+ const entry = findAssistantByName(name);
64
+ if (!entry) {
65
+ console.error(`No assistant found with name '${name}'.`);
66
+ console.error("Run 'vellum hatch' first, or check the instance name.");
67
+ process.exit(1);
68
+ }
69
+
70
+ // Obtain an auth token
71
+ let accessToken: string;
72
+ const tokenData = loadGuardianToken(entry.assistantId);
73
+ if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
74
+ accessToken = tokenData.accessToken;
75
+ } else {
76
+ try {
77
+ const freshToken = await leaseGuardianToken(
78
+ entry.runtimeUrl,
79
+ entry.assistantId,
80
+ );
81
+ accessToken = freshToken.accessToken;
82
+ } catch (err) {
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
85
+ console.error(
86
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
87
+ );
88
+ console.error(`Try: vellum wake ${name}`);
89
+ process.exit(1);
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ // Call the export endpoint
96
+ let response: Response;
97
+ try {
98
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
99
+ method: "POST",
100
+ headers: {
101
+ Authorization: `Bearer ${accessToken}`,
102
+ "Content-Type": "application/json",
103
+ },
104
+ body: JSON.stringify({ description: "CLI backup" }),
105
+ signal: AbortSignal.timeout(120_000),
106
+ });
107
+ } catch (err) {
108
+ if (err instanceof Error && err.name === "TimeoutError") {
109
+ console.error("Error: Export request timed out after 2 minutes.");
110
+ process.exit(1);
111
+ }
112
+ const msg = err instanceof Error ? err.message : String(err);
113
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
114
+ console.error(
115
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
116
+ );
117
+ console.error(`Try: vellum wake ${name}`);
118
+ process.exit(1);
119
+ }
120
+ throw err;
121
+ }
122
+
123
+ if (!response.ok) {
124
+ const body = await response.text();
125
+ console.error(`Error: Export failed (${response.status}): ${body}`);
126
+ process.exit(1);
127
+ }
128
+
129
+ // Read the response body
130
+ const arrayBuffer = await response.arrayBuffer();
131
+ const data = new Uint8Array(arrayBuffer);
132
+
133
+ // Determine output path
134
+ const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
135
+ const outputPath =
136
+ outputArg || join(getBackupsDir(), `${name}-${isoTimestamp}.vbundle`);
137
+
138
+ // Ensure parent directory exists
139
+ mkdirSync(dirname(outputPath), { recursive: true });
140
+
141
+ // Write the archive to disk
142
+ writeFileSync(outputPath, data);
143
+
144
+ // Print success
145
+ const manifestSha = response.headers.get("X-Vbundle-Manifest-Sha256");
146
+ console.log(`Backup saved to ${outputPath}`);
147
+ console.log(`Size: ${formatSize(data.byteLength)}`);
148
+ if (manifestSha) {
149
+ console.log(`Manifest SHA-256: ${manifestSha}`);
150
+ }
151
+ }
@@ -96,7 +96,7 @@ chown -R "$SSH_USER:$SSH_USER" "$SSH_USER_HOME" 2>/dev/null || true
96
96
  export async function buildStartupScript(
97
97
  species: Species,
98
98
  sshUser: string,
99
- anthropicApiKey: string,
99
+ providerApiKeys: Record<string, string>,
100
100
  instanceName: string,
101
101
  cloud: RemoteHost,
102
102
  ): Promise<string> {
@@ -114,13 +114,22 @@ export async function buildStartupScript(
114
114
  if (species === "openclaw") {
115
115
  return await buildOpenclawStartupScript(
116
116
  sshUser,
117
- anthropicApiKey,
117
+ providerApiKeys,
118
118
  timestampRedirect,
119
119
  userSetup,
120
120
  ownershipFixup,
121
121
  );
122
122
  }
123
123
 
124
+ // Build bash lines that set each provider API key as a shell variable
125
+ // and corresponding dotenv lines for the env file.
126
+ const envSetLines = Object.entries(providerApiKeys)
127
+ .map(([envVar, value]) => `${envVar}=${value}`)
128
+ .join("\n");
129
+ const dotenvLines = Object.keys(providerApiKeys)
130
+ .map((envVar) => `${envVar}=\$${envVar}`)
131
+ .join("\n");
132
+
124
133
  return `#!/bin/bash
125
134
  set -e
126
135
 
@@ -128,11 +137,11 @@ ${timestampRedirect}
128
137
 
129
138
  trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE at line \$LINENO" > ${errorPath}; echo "Last 20 log lines:" >> ${errorPath}; tail -20 ${logPath} >> ${errorPath} 2>/dev/null || true; fi' EXIT
130
139
  ${userSetup}
131
- ANTHROPIC_API_KEY=${anthropicApiKey}
140
+ ${envSetLines}
132
141
  VELLUM_ASSISTANT_NAME=${instanceName}
133
142
  mkdir -p "\$HOME/.config/vellum"
134
143
  cat > "\$HOME/.config/vellum/env" << DOTENV_EOF
135
- ANTHROPIC_API_KEY=\$ANTHROPIC_API_KEY
144
+ ${dotenvLines}
136
145
  RUNTIME_HTTP_PORT=7821
137
146
  DOTENV_EOF
138
147
 
@@ -749,6 +758,7 @@ async function hatchLocal(
749
758
  cloud: "local",
750
759
  species,
751
760
  hatchedAt: new Date().toISOString(),
761
+ serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
752
762
  resources,
753
763
  };
754
764
  if (!daemonOnly && !restart) {
@@ -0,0 +1,310 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+
3
+ import { findAssistantByName } from "../lib/assistant-config.js";
4
+ import {
5
+ loadGuardianToken,
6
+ leaseGuardianToken,
7
+ } from "../lib/guardian-token.js";
8
+
9
+ function printUsage(): void {
10
+ console.log("Usage: vellum restore <name> --from <path> [--dry-run]");
11
+ console.log("");
12
+ console.log("Restore a .vbundle backup into a running assistant.");
13
+ console.log("");
14
+ console.log("Arguments:");
15
+ console.log(" <name> Name of the assistant to restore into");
16
+ console.log("");
17
+ console.log("Options:");
18
+ console.log(
19
+ " --from <path> Path to the .vbundle file to restore (required)",
20
+ );
21
+ console.log(" --dry-run Show what would change without applying");
22
+ console.log("");
23
+ console.log("Examples:");
24
+ console.log(" vellum restore my-assistant --from ~/Desktop/backup.vbundle");
25
+ console.log(
26
+ " vellum restore my-assistant --from ~/Desktop/backup.vbundle --dry-run",
27
+ );
28
+ }
29
+
30
+ function parseArgs(argv: string[]): {
31
+ name: string | undefined;
32
+ fromPath: string | undefined;
33
+ dryRun: boolean;
34
+ help: boolean;
35
+ } {
36
+ const args = argv.slice(3);
37
+
38
+ if (args.includes("--help") || args.includes("-h")) {
39
+ return { name: undefined, fromPath: undefined, dryRun: false, help: true };
40
+ }
41
+
42
+ let fromPath: string | undefined;
43
+ const dryRun = args.includes("--dry-run");
44
+ const positionals: string[] = [];
45
+
46
+ for (let i = 0; i < args.length; i++) {
47
+ if (args[i] === "--from" && args[i + 1]) {
48
+ fromPath = args[i + 1];
49
+ i++; // skip the value
50
+ } else if (args[i] === "--dry-run") {
51
+ // already handled above
52
+ } else if (!args[i].startsWith("-")) {
53
+ positionals.push(args[i]);
54
+ }
55
+ }
56
+
57
+ return { name: positionals[0], fromPath, dryRun, help: false };
58
+ }
59
+
60
+ async function getAccessToken(
61
+ runtimeUrl: string,
62
+ assistantId: string,
63
+ displayName: string,
64
+ ): Promise<string> {
65
+ const tokenData = loadGuardianToken(assistantId);
66
+
67
+ if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
68
+ return tokenData.accessToken;
69
+ }
70
+
71
+ try {
72
+ const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
73
+ return freshToken.accessToken;
74
+ } catch (err) {
75
+ const msg = err instanceof Error ? err.message : String(err);
76
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
77
+ console.error(
78
+ `Error: Could not connect to assistant '${displayName}'. Is it running?`,
79
+ );
80
+ console.error(`Try: vellum wake ${displayName}`);
81
+ process.exit(1);
82
+ }
83
+ throw err;
84
+ }
85
+ }
86
+
87
+ interface PreflightFileEntry {
88
+ path: string;
89
+ action: string;
90
+ }
91
+
92
+ interface PreflightResponse {
93
+ can_import: boolean;
94
+ errors?: string[];
95
+ files?: PreflightFileEntry[];
96
+ summary?: {
97
+ create: number;
98
+ overwrite: number;
99
+ unchanged: number;
100
+ total: number;
101
+ };
102
+ conflicts?: string[];
103
+ }
104
+
105
+ interface ImportResponse {
106
+ success: boolean;
107
+ reason?: string;
108
+ errors?: string[];
109
+ warnings?: string[];
110
+ summary?: {
111
+ created: number;
112
+ overwritten: number;
113
+ skipped: number;
114
+ backups_created: number;
115
+ };
116
+ }
117
+
118
+ export async function restore(): Promise<void> {
119
+ const { name, fromPath, dryRun, help } = parseArgs(process.argv);
120
+
121
+ if (help) {
122
+ printUsage();
123
+ process.exit(0);
124
+ }
125
+
126
+ if (!name || !fromPath) {
127
+ console.error("Error: Both <name> and --from <path> are required.");
128
+ console.error("");
129
+ printUsage();
130
+ process.exit(1);
131
+ }
132
+
133
+ // Look up the instance
134
+ const entry = findAssistantByName(name);
135
+ if (!entry) {
136
+ console.error(`Error: No assistant found with name '${name}'.`);
137
+ console.error("Run 'vellum ps' to see available assistants.");
138
+ process.exit(1);
139
+ }
140
+
141
+ // Verify .vbundle file exists
142
+ if (!existsSync(fromPath)) {
143
+ console.error(`Error: File not found: ${fromPath}`);
144
+ process.exit(1);
145
+ }
146
+
147
+ // Read the .vbundle file
148
+ const bundleData = readFileSync(fromPath);
149
+ const sizeMB = (bundleData.byteLength / (1024 * 1024)).toFixed(2);
150
+ console.log(`Reading ${fromPath} (${sizeMB} MB)...`);
151
+
152
+ // Obtain auth token
153
+ const accessToken = await getAccessToken(
154
+ entry.runtimeUrl,
155
+ entry.assistantId,
156
+ name,
157
+ );
158
+
159
+ if (dryRun) {
160
+ // Preflight check
161
+ console.log("Running preflight analysis...\n");
162
+
163
+ let response: Response;
164
+ try {
165
+ response = await fetch(
166
+ `${entry.runtimeUrl}/v1/migrations/import-preflight`,
167
+ {
168
+ method: "POST",
169
+ headers: {
170
+ Authorization: `Bearer ${accessToken}`,
171
+ "Content-Type": "application/octet-stream",
172
+ },
173
+ body: bundleData,
174
+ signal: AbortSignal.timeout(120_000),
175
+ },
176
+ );
177
+ } catch (err) {
178
+ if (err instanceof Error && err.name === "TimeoutError") {
179
+ console.error("Error: Preflight request timed out after 2 minutes.");
180
+ process.exit(1);
181
+ }
182
+ const msg = err instanceof Error ? err.message : String(err);
183
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
184
+ console.error(
185
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
186
+ );
187
+ console.error(`Try: vellum wake ${name}`);
188
+ process.exit(1);
189
+ }
190
+ throw err;
191
+ }
192
+
193
+ if (!response.ok) {
194
+ const body = await response.text();
195
+ console.error(
196
+ `Error: Preflight check failed (${response.status}): ${body}`,
197
+ );
198
+ process.exit(1);
199
+ }
200
+
201
+ const result = (await response.json()) as PreflightResponse;
202
+
203
+ if (!result.can_import) {
204
+ console.error("Import blocked by validation errors:");
205
+ for (const err of result.errors ?? []) {
206
+ console.error(` - ${err}`);
207
+ }
208
+ process.exit(1);
209
+ }
210
+
211
+ // Print summary table
212
+ const summary = result.summary ?? {
213
+ create: 0,
214
+ overwrite: 0,
215
+ unchanged: 0,
216
+ total: 0,
217
+ };
218
+ console.log("Preflight analysis:");
219
+ console.log(` Files to create: ${summary.create}`);
220
+ console.log(` Files to overwrite: ${summary.overwrite}`);
221
+ console.log(` Files unchanged: ${summary.unchanged}`);
222
+ console.log(` Total: ${summary.total}`);
223
+ console.log("");
224
+
225
+ const conflicts = result.conflicts ?? [];
226
+ console.log(
227
+ `Conflicts: ${conflicts.length > 0 ? conflicts.join(", ") : "none"}`,
228
+ );
229
+
230
+ // List individual files with their action
231
+ if (result.files && result.files.length > 0) {
232
+ console.log("");
233
+ console.log("Files:");
234
+ for (const file of result.files) {
235
+ console.log(` [${file.action}] ${file.path}`);
236
+ }
237
+ }
238
+ } else {
239
+ // Full import
240
+ console.log("Importing backup...\n");
241
+
242
+ let response: Response;
243
+ try {
244
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
245
+ method: "POST",
246
+ headers: {
247
+ Authorization: `Bearer ${accessToken}`,
248
+ "Content-Type": "application/octet-stream",
249
+ },
250
+ body: bundleData,
251
+ signal: AbortSignal.timeout(120_000),
252
+ });
253
+ } catch (err) {
254
+ if (err instanceof Error && err.name === "TimeoutError") {
255
+ console.error("Error: Import request timed out after 2 minutes.");
256
+ process.exit(1);
257
+ }
258
+ const msg = err instanceof Error ? err.message : String(err);
259
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
260
+ console.error(
261
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
262
+ );
263
+ console.error(`Try: vellum wake ${name}`);
264
+ process.exit(1);
265
+ }
266
+ throw err;
267
+ }
268
+
269
+ if (!response.ok) {
270
+ const body = await response.text();
271
+ console.error(`Error: Import failed (${response.status}): ${body}`);
272
+ process.exit(1);
273
+ }
274
+
275
+ const result = (await response.json()) as ImportResponse;
276
+
277
+ if (!result.success) {
278
+ console.error(
279
+ `Error: Import failed — ${result.reason ?? "unknown reason"}`,
280
+ );
281
+ for (const err of result.errors ?? []) {
282
+ console.error(` - ${err}`);
283
+ }
284
+ process.exit(1);
285
+ }
286
+
287
+ // Print import report
288
+ const summary = result.summary ?? {
289
+ created: 0,
290
+ overwritten: 0,
291
+ skipped: 0,
292
+ backups_created: 0,
293
+ };
294
+ console.log("✅ Restore complete.");
295
+ console.log(` Files created: ${summary.created}`);
296
+ console.log(` Files overwritten: ${summary.overwritten}`);
297
+ console.log(` Files skipped: ${summary.skipped}`);
298
+ console.log(` Backups created: ${summary.backups_created}`);
299
+
300
+ // Print warnings if any
301
+ const warnings = result.warnings ?? [];
302
+ if (warnings.length > 0) {
303
+ console.log("");
304
+ console.log("Warnings:");
305
+ for (const warning of warnings) {
306
+ console.log(` ⚠️ ${warning}`);
307
+ }
308
+ }
309
+ }
310
+ }
@@ -1,9 +1,12 @@
1
+ import { randomBytes } from "crypto";
2
+
1
3
  import cliPkg from "../../package.json";
2
4
 
3
5
  import {
4
6
  findAssistantByName,
5
7
  getActiveAssistant,
6
8
  loadAllAssistants,
9
+ saveAssistantEntry,
7
10
  } from "../lib/assistant-config";
8
11
  import type { AssistantEntry } from "../lib/assistant-config";
9
12
  import {
@@ -12,6 +15,8 @@ import {
12
15
  DOCKER_READY_TIMEOUT_MS,
13
16
  GATEWAY_INTERNAL_PORT,
14
17
  dockerResourceNames,
18
+ migrateCesSecurityFiles,
19
+ migrateGatewaySecurityFiles,
15
20
  startContainers,
16
21
  stopContainers,
17
22
  } from "../lib/docker";
@@ -257,10 +262,18 @@ async function upgradeDocker(
257
262
  // use default
258
263
  }
259
264
 
265
+ // Extract CES_SERVICE_TOKEN from the captured env so it can be passed via
266
+ // the dedicated cesServiceToken parameter (which propagates it to all three
267
+ // containers). If the old instance predates CES_SERVICE_TOKEN, generate a
268
+ // fresh one so gateway and CES can authenticate.
269
+ const cesServiceToken =
270
+ capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
271
+
260
272
  // Build the set of extra env vars to replay on the new assistant container.
261
273
  // Captured env vars serve as the base; keys already managed by
262
274
  // serviceDockerRunArgs are excluded to avoid duplicates.
263
275
  const envKeysSetByRunArgs = new Set([
276
+ "CES_SERVICE_TOKEN",
264
277
  "VELLUM_ASSISTANT_NAME",
265
278
  "RUNTIME_HTTP_HOST",
266
279
  "PATH",
@@ -278,9 +291,16 @@ async function upgradeDocker(
278
291
  }
279
292
  }
280
293
 
294
+ console.log("🔄 Migrating security files to gateway volume...");
295
+ await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
296
+
297
+ console.log("🔄 Migrating credential files to CES security volume...");
298
+ await migrateCesSecurityFiles(res, (msg) => console.log(msg));
299
+
281
300
  console.log("🚀 Starting upgraded containers...");
282
301
  await startContainers(
283
302
  {
303
+ cesServiceToken,
284
304
  extraAssistantEnv,
285
305
  gatewayPort,
286
306
  imageTags,
@@ -294,6 +314,23 @@ async function upgradeDocker(
294
314
  console.log("Waiting for assistant to become ready...");
295
315
  const ready = await waitForReady(entry.runtimeUrl);
296
316
  if (ready) {
317
+ // Update lockfile with new service group topology
318
+ const newDigests = await captureImageRefs(res);
319
+ const updatedEntry: AssistantEntry = {
320
+ ...entry,
321
+ serviceGroupVersion: versionTag,
322
+ containerInfo: {
323
+ assistantImage: imageTags.assistant,
324
+ gatewayImage: imageTags.gateway,
325
+ cesImage: imageTags["credential-executor"],
326
+ assistantDigest: newDigests?.assistant,
327
+ gatewayDigest: newDigests?.gateway,
328
+ cesDigest: newDigests?.["credential-executor"],
329
+ networkName: res.network,
330
+ },
331
+ };
332
+ saveAssistantEntry(updatedEntry);
333
+
297
334
  console.log(
298
335
  `\n✅ Docker assistant '${instanceName}' upgraded to ${versionTag}.`,
299
336
  );
@@ -307,6 +344,7 @@ async function upgradeDocker(
307
344
 
308
345
  await startContainers(
309
346
  {
347
+ cesServiceToken,
310
348
  extraAssistantEnv,
311
349
  gatewayPort,
312
350
  imageTags: previousImageRefs,
@@ -318,6 +356,19 @@ async function upgradeDocker(
318
356
 
319
357
  const rollbackReady = await waitForReady(entry.runtimeUrl);
320
358
  if (rollbackReady) {
359
+ // Restore previous container info in lockfile after rollback
360
+ if (previousImageRefs) {
361
+ const rolledBackEntry: AssistantEntry = {
362
+ ...entry,
363
+ containerInfo: {
364
+ assistantImage: previousImageRefs.assistant,
365
+ gatewayImage: previousImageRefs.gateway,
366
+ cesImage: previousImageRefs["credential-executor"],
367
+ networkName: res.network,
368
+ },
369
+ };
370
+ saveAssistantEntry(rolledBackEntry);
371
+ }
321
372
  console.log(
322
373
  `\n⚠️ Rolled back to previous version. Upgrade to ${versionTag} failed.`,
323
374
  );
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import cliPkg from "../package.json";
4
+ import { backup } from "./commands/backup";
4
5
  import { clean } from "./commands/clean";
5
6
  import { client } from "./commands/client";
6
7
  import { hatch } from "./commands/hatch";
@@ -8,6 +9,7 @@ import { login, logout, whoami } from "./commands/login";
8
9
  import { pair } from "./commands/pair";
9
10
  import { ps } from "./commands/ps";
10
11
  import { recover } from "./commands/recover";
12
+ import { restore } from "./commands/restore";
11
13
  import { retire } from "./commands/retire";
12
14
  import { setup } from "./commands/setup";
13
15
  import { sleep } from "./commands/sleep";
@@ -26,6 +28,7 @@ import { loadGuardianToken } from "./lib/guardian-token";
26
28
  import { checkHealth } from "./lib/health-check";
27
29
 
28
30
  const commands = {
31
+ backup,
29
32
  clean,
30
33
  client,
31
34
  hatch,
@@ -34,6 +37,7 @@ const commands = {
34
37
  pair,
35
38
  ps,
36
39
  recover,
40
+ restore,
37
41
  retire,
38
42
  setup,
39
43
  sleep,
@@ -51,6 +55,7 @@ function printHelp(): void {
51
55
  console.log("Usage: vellum <command> [options]");
52
56
  console.log("");
53
57
  console.log("Commands:");
58
+ console.log(" backup Export a backup of a running assistant");
54
59
  console.log(" clean Kill orphaned vellum processes");
55
60
  console.log(" client Connect to a hatched assistant");
56
61
  console.log(" hatch Create a new assistant instance");
@@ -61,6 +66,7 @@ function printHelp(): void {
61
66
  " ps List assistants (or processes for a specific assistant)",
62
67
  );
63
68
  console.log(" recover Restore a previously retired local assistant");
69
+ console.log(" restore Restore a .vbundle backup into a running assistant");
64
70
  console.log(" retire Delete an assistant instance");
65
71
  console.log(" setup Configure API keys interactively");
66
72
  console.log(" sleep Stop the assistant process");
@@ -4,6 +4,7 @@ import { join } from "path";
4
4
 
5
5
  import {
6
6
  DAEMON_INTERNAL_ASSISTANT_ID,
7
+ DEFAULT_CES_PORT,
7
8
  DEFAULT_DAEMON_PORT,
8
9
  DEFAULT_GATEWAY_PORT,
9
10
  DEFAULT_QDRANT_PORT,
@@ -29,11 +30,28 @@ export interface LocalInstanceResources {
29
30
  gatewayPort: number;
30
31
  /** HTTP port for the Qdrant vector store */
31
32
  qdrantPort: number;
33
+ /** HTTP port for the CES (Claude Extension Server) */
34
+ cesPort: number;
32
35
  /** Absolute path to the daemon PID file */
33
36
  pidFile: string;
34
37
  [key: string]: unknown;
35
38
  }
36
39
 
40
+ /** Docker image metadata for the service group. Enables rollback to known-good digests. */
41
+ export interface ContainerInfo {
42
+ assistantImage: string;
43
+ gatewayImage: string;
44
+ cesImage: string;
45
+ /** sha256 digest of the assistant image at time of hatch/upgrade */
46
+ assistantDigest?: string;
47
+ /** sha256 digest of the gateway image at time of hatch/upgrade */
48
+ gatewayDigest?: string;
49
+ /** sha256 digest of the CES image at time of hatch/upgrade */
50
+ cesDigest?: string;
51
+ /** Docker network name for the service group */
52
+ networkName?: string;
53
+ }
54
+
37
55
  export interface AssistantEntry {
38
56
  assistantId: string;
39
57
  runtimeUrl: string;
@@ -56,6 +74,10 @@ export interface AssistantEntry {
56
74
  resources?: LocalInstanceResources;
57
75
  /** PID of the file watcher process for docker instances hatched with --watch. */
58
76
  watcherPid?: number;
77
+ /** Last-known version of the service group, populated at hatch and updated by health checks. */
78
+ serviceGroupVersion?: string;
79
+ /** Docker image metadata for rollback. Only present for docker topology entries. */
80
+ containerInfo?: ContainerInfo;
59
81
  [key: string]: unknown;
60
82
  }
61
83
 
@@ -166,6 +188,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
166
188
  daemonPort: DEFAULT_DAEMON_PORT,
167
189
  gatewayPort,
168
190
  qdrantPort: DEFAULT_QDRANT_PORT,
191
+ cesPort: DEFAULT_CES_PORT,
169
192
  pidFile: join(instanceDir, ".vellum", "vellum.pid"),
170
193
  };
171
194
  mutated = true;
@@ -198,6 +221,10 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
198
221
  res.qdrantPort = DEFAULT_QDRANT_PORT;
199
222
  mutated = true;
200
223
  }
224
+ if (typeof res.cesPort !== "number") {
225
+ res.cesPort = DEFAULT_CES_PORT;
226
+ mutated = true;
227
+ }
201
228
  if (typeof res.pidFile !== "string") {
202
229
  res.pidFile = join(res.instanceDir as string, ".vellum", "vellum.pid");
203
230
  mutated = true;
@@ -373,6 +400,7 @@ export async function allocateLocalResources(
373
400
  daemonPort: DEFAULT_DAEMON_PORT,
374
401
  gatewayPort: DEFAULT_GATEWAY_PORT,
375
402
  qdrantPort: DEFAULT_QDRANT_PORT,
403
+ cesPort: DEFAULT_CES_PORT,
376
404
  pidFile: join(vellumDir, "vellum.pid"),
377
405
  };
378
406
  }
@@ -423,6 +451,7 @@ export async function allocateLocalResources(
423
451
  daemonPort,
424
452
  gatewayPort,
425
453
  qdrantPort,
454
+ cesPort: DEFAULT_CES_PORT,
426
455
  pidFile: join(instanceDir, ".vellum", "vellum.pid"),
427
456
  };
428
457
  }
package/src/lib/aws.ts CHANGED
@@ -6,7 +6,7 @@ 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 } from "./constants";
9
+ import { GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
10
10
  import type { Species } from "./constants";
11
11
  import { leaseGuardianToken } from "./guardian-token";
12
12
  import { generateInstanceName } from "./random-name";
@@ -410,10 +410,18 @@ export async function hatchAws(
410
410
 
411
411
  const sshUser = userInfo().username;
412
412
  const hatchedBy = process.env.VELLUM_HATCHED_BY;
413
- const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
414
- if (!anthropicApiKey) {
413
+ const providerApiKeys: Record<string, string> = {};
414
+ for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
415
+ const value = process.env[envVar];
416
+ if (value) {
417
+ providerApiKeys[envVar] = value;
418
+ }
419
+ }
420
+ if (Object.keys(providerApiKeys).length === 0) {
415
421
  console.error(
416
- "Error: ANTHROPIC_API_KEY environment variable is not set.",
422
+ "Error: No provider API key environment variable is set. " +
423
+ "Set at least one of: " +
424
+ Object.values(PROVIDER_ENV_VAR_NAMES).join(", "),
417
425
  );
418
426
  process.exit(1);
419
427
  }
@@ -437,7 +445,7 @@ export async function hatchAws(
437
445
  const startupScript = await buildStartupScript(
438
446
  species,
439
447
  sshUser,
440
- anthropicApiKey,
448
+ providerApiKeys,
441
449
  instanceName,
442
450
  "aws",
443
451
  );
@@ -1,3 +1,5 @@
1
+ import providerEnvVarsRegistry from "../../../meta/provider-env-vars.json";
2
+
1
3
  /**
2
4
  * Canonical internal assistant ID used as the default/fallback across the CLI
3
5
  * and daemon. Mirrors `DAEMON_INTERNAL_ASSISTANT_ID` from
@@ -14,6 +16,16 @@ export const GATEWAY_PORT = process.env.GATEWAY_PORT
14
16
  export const DEFAULT_DAEMON_PORT = 7821;
15
17
  export const DEFAULT_GATEWAY_PORT = 7830;
16
18
  export const DEFAULT_QDRANT_PORT = 6333;
19
+ export const DEFAULT_CES_PORT = 8090;
20
+
21
+ /**
22
+ * Environment variable names for provider API keys, keyed by provider ID.
23
+ * Loaded from the shared registry at `meta/provider-env-vars.json` — the
24
+ * single source of truth also consumed by the assistant runtime and the
25
+ * macOS client.
26
+ */
27
+ export const PROVIDER_ENV_VAR_NAMES: Record<string, string> =
28
+ providerEnvVarsRegistry.providers;
17
29
 
18
30
  export const VALID_REMOTE_HOSTS = [
19
31
  "local",
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`,
538
+ "-e",
539
+ "WORKSPACE_DIR=/workspace",
505
540
  "-e",
506
- "BASE_DATA_DIR=/data",
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>;
@@ -573,7 +764,11 @@ export async function stopContainers(
573
764
  export async function sleepContainers(
574
765
  res: ReturnType<typeof dockerResourceNames>,
575
766
  ): Promise<void> {
576
- for (const container of [res.cesContainer, res.gatewayContainer, res.assistantContainer]) {
767
+ for (const container of [
768
+ res.cesContainer,
769
+ res.gatewayContainer,
770
+ res.assistantContainer,
771
+ ]) {
577
772
  try {
578
773
  await exec("docker", ["stop", container]);
579
774
  } catch {
@@ -586,7 +781,11 @@ export async function sleepContainers(
586
781
  export async function wakeContainers(
587
782
  res: ReturnType<typeof dockerResourceNames>,
588
783
  ): Promise<void> {
589
- for (const container of [res.assistantContainer, res.gatewayContainer, res.cesContainer]) {
784
+ for (const container of [
785
+ res.assistantContainer,
786
+ res.gatewayContainer,
787
+ res.cesContainer,
788
+ ]) {
590
789
  await exec("docker", ["start", container]);
591
790
  }
592
791
  }
@@ -867,10 +1066,18 @@ export async function hatchDocker(
867
1066
 
868
1067
  log("📁 Creating network and volumes...");
869
1068
  await exec("docker", ["network", "create", res.network]);
870
- await exec("docker", ["volume", "create", res.dataVolume]);
871
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
+ );
872
1079
 
873
- await startContainers({ gatewayPort, imageTags, instanceName, res }, log);
1080
+ const imageDigests = await captureImageRefs(res);
874
1081
 
875
1082
  const runtimeUrl = `http://localhost:${gatewayPort}`;
876
1083
  const dockerEntry: AssistantEntry = {
@@ -880,6 +1087,16 @@ export async function hatchDocker(
880
1087
  species,
881
1088
  hatchedAt: new Date().toISOString(),
882
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
+ },
883
1100
  };
884
1101
  saveAssistantEntry(dockerEntry);
885
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
  );