@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 +12 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +46 -0
- package/src/adapters/openclaw.ts +4 -2
- package/src/commands/backup.ts +151 -0
- package/src/commands/hatch.ts +14 -4
- package/src/commands/restore.ts +310 -0
- package/src/commands/upgrade.ts +51 -0
- package/src/index.ts +6 -0
- package/src/lib/assistant-config.ts +29 -0
- package/src/lib/aws.ts +13 -5
- package/src/lib/constants.ts +12 -0
- package/src/lib/docker.ts +230 -13
- package/src/lib/gcp.ts +18 -6
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
|
@@ -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", () => {
|
package/src/adapters/openclaw.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { buildOpenclawRuntimeServer } from "../lib/openclaw-runtime-server";
|
|
|
3
3
|
|
|
4
4
|
export async function buildOpenclawStartupScript(
|
|
5
5
|
sshUser: string,
|
|
6
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/hatch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
|
414
|
-
|
|
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:
|
|
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
|
-
|
|
448
|
+
providerApiKeys,
|
|
441
449
|
instanceName,
|
|
442
450
|
"aws",
|
|
443
451
|
);
|
package/src/lib/constants.ts
CHANGED
|
@@ -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 [
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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.
|
|
535
|
+
`${res.workspaceVolume}:/workspace`,
|
|
536
|
+
"-v",
|
|
537
|
+
`${res.gatewaySecurityVolume}:/gateway-security`,
|
|
538
|
+
"-e",
|
|
539
|
+
"WORKSPACE_DIR=/workspace",
|
|
505
540
|
"-e",
|
|
506
|
-
"
|
|
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.
|
|
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
|
-
"
|
|
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 [
|
|
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 [
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
504
|
-
|
|
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:
|
|
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
|
-
|
|
525
|
+
providerApiKeys,
|
|
514
526
|
instanceName,
|
|
515
527
|
"gcp",
|
|
516
528
|
);
|