@vellumai/cli 0.5.4 → 0.5.6
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/knip.json +2 -1
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +46 -0
- package/src/__tests__/health-check.test.ts +26 -1
- package/src/adapters/openclaw.ts +4 -2
- package/src/commands/backup.ts +151 -0
- package/src/commands/hatch.ts +14 -4
- package/src/commands/ps.ts +6 -1
- package/src/commands/restore.ts +330 -0
- package/src/commands/rollback.ts +280 -0
- package/src/commands/upgrade.ts +171 -2
- package/src/commands/wake.ts +8 -0
- package/src/index.ts +11 -0
- package/src/lib/assistant-config.ts +57 -0
- package/src/lib/aws.ts +13 -5
- package/src/lib/constants.ts +12 -0
- package/src/lib/docker.ts +299 -18
- package/src/lib/gcp.ts +18 -6
- package/src/lib/guardian-token.ts +46 -1
- package/src/lib/health-check.ts +4 -0
- package/src/lib/platform-client.ts +3 -2
- package/src/lib/version-compat.ts +45 -0
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,9 +12,9 @@ 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
|
-
import { leaseGuardianToken } from "./guardian-token";
|
|
17
|
+
import { leaseGuardianToken, saveBootstrapSecret } from "./guardian-token";
|
|
17
18
|
import { isVellumProcess, stopProcess } from "./process";
|
|
18
19
|
import { generateInstanceName } from "./random-name";
|
|
19
20
|
import { exec, execOutput } from "./step-runner";
|
|
@@ -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,22 @@ async function buildAllImages(
|
|
|
453
464
|
* can be restarted independently.
|
|
454
465
|
*/
|
|
455
466
|
export function serviceDockerRunArgs(opts: {
|
|
467
|
+
bootstrapSecret?: string;
|
|
468
|
+
cesServiceToken?: string;
|
|
456
469
|
extraAssistantEnv?: Record<string, string>;
|
|
457
470
|
gatewayPort: number;
|
|
458
471
|
imageTags: Record<ServiceName, string>;
|
|
459
472
|
instanceName: string;
|
|
460
473
|
res: ReturnType<typeof dockerResourceNames>;
|
|
461
474
|
}): Record<ServiceName, () => string[]> {
|
|
462
|
-
const {
|
|
475
|
+
const {
|
|
476
|
+
cesServiceToken,
|
|
477
|
+
extraAssistantEnv,
|
|
478
|
+
gatewayPort,
|
|
479
|
+
imageTags,
|
|
480
|
+
instanceName,
|
|
481
|
+
res,
|
|
482
|
+
} = opts;
|
|
463
483
|
return {
|
|
464
484
|
assistant: () => {
|
|
465
485
|
const args: string[] = [
|
|
@@ -470,15 +490,27 @@ export function serviceDockerRunArgs(opts: {
|
|
|
470
490
|
res.assistantContainer,
|
|
471
491
|
`--network=${res.network}`,
|
|
472
492
|
"-v",
|
|
473
|
-
`${res.
|
|
493
|
+
`${res.workspaceVolume}:/workspace`,
|
|
474
494
|
"-v",
|
|
475
495
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
476
496
|
"-e",
|
|
477
497
|
`VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
478
498
|
"-e",
|
|
479
499
|
"RUNTIME_HTTP_HOST=0.0.0.0",
|
|
500
|
+
"-e",
|
|
501
|
+
"WORKSPACE_DIR=/workspace",
|
|
502
|
+
"-e",
|
|
503
|
+
`CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
|
|
504
|
+
"-e",
|
|
505
|
+
`GATEWAY_INTERNAL_URL=http://${res.gatewayContainer}:${GATEWAY_INTERNAL_PORT}`,
|
|
480
506
|
];
|
|
481
|
-
|
|
507
|
+
if (cesServiceToken) {
|
|
508
|
+
args.push("-e", `CES_SERVICE_TOKEN=${cesServiceToken}`);
|
|
509
|
+
}
|
|
510
|
+
for (const envVar of [
|
|
511
|
+
...Object.values(PROVIDER_ENV_VAR_NAMES),
|
|
512
|
+
"VELLUM_PLATFORM_URL",
|
|
513
|
+
]) {
|
|
482
514
|
if (process.env[envVar]) {
|
|
483
515
|
args.push("-e", `${envVar}=${process.env[envVar]}`);
|
|
484
516
|
}
|
|
@@ -501,9 +533,13 @@ export function serviceDockerRunArgs(opts: {
|
|
|
501
533
|
"-p",
|
|
502
534
|
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
503
535
|
"-v",
|
|
504
|
-
`${res.
|
|
536
|
+
`${res.workspaceVolume}:/workspace`,
|
|
537
|
+
"-v",
|
|
538
|
+
`${res.gatewaySecurityVolume}:/gateway-security`,
|
|
505
539
|
"-e",
|
|
506
|
-
"
|
|
540
|
+
"WORKSPACE_DIR=/workspace",
|
|
541
|
+
"-e",
|
|
542
|
+
"GATEWAY_SECURITY_DIR=/gateway-security",
|
|
507
543
|
"-e",
|
|
508
544
|
`GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
|
|
509
545
|
"-e",
|
|
@@ -512,6 +548,14 @@ export function serviceDockerRunArgs(opts: {
|
|
|
512
548
|
`RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
|
|
513
549
|
"-e",
|
|
514
550
|
"RUNTIME_PROXY_ENABLED=true",
|
|
551
|
+
"-e",
|
|
552
|
+
`CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
|
|
553
|
+
...(cesServiceToken
|
|
554
|
+
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
555
|
+
: []),
|
|
556
|
+
...(opts.bootstrapSecret
|
|
557
|
+
? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
|
|
558
|
+
: []),
|
|
515
559
|
imageTags.gateway,
|
|
516
560
|
],
|
|
517
561
|
"credential-executor": () => [
|
|
@@ -520,21 +564,171 @@ export function serviceDockerRunArgs(opts: {
|
|
|
520
564
|
"-d",
|
|
521
565
|
"--name",
|
|
522
566
|
res.cesContainer,
|
|
567
|
+
`--network=${res.network}`,
|
|
523
568
|
"-v",
|
|
524
569
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
525
570
|
"-v",
|
|
526
|
-
`${res.
|
|
571
|
+
`${res.workspaceVolume}:/workspace:ro`,
|
|
572
|
+
"-v",
|
|
573
|
+
`${res.cesSecurityVolume}:/ces-security`,
|
|
527
574
|
"-e",
|
|
528
575
|
"CES_MODE=managed",
|
|
529
576
|
"-e",
|
|
577
|
+
"WORKSPACE_DIR=/workspace",
|
|
578
|
+
"-e",
|
|
530
579
|
"CES_BOOTSTRAP_SOCKET_DIR=/run/ces-bootstrap",
|
|
531
580
|
"-e",
|
|
532
|
-
"
|
|
581
|
+
"CREDENTIAL_SECURITY_DIR=/ces-security",
|
|
582
|
+
...(cesServiceToken
|
|
583
|
+
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
584
|
+
: []),
|
|
533
585
|
imageTags["credential-executor"],
|
|
534
586
|
],
|
|
535
587
|
};
|
|
536
588
|
}
|
|
537
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Check whether a Docker volume exists.
|
|
592
|
+
* Returns true if the volume exists, false otherwise.
|
|
593
|
+
*/
|
|
594
|
+
async function dockerVolumeExists(volumeName: string): Promise<boolean> {
|
|
595
|
+
try {
|
|
596
|
+
await execOutput("docker", ["volume", "inspect", volumeName]);
|
|
597
|
+
return true;
|
|
598
|
+
} catch {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Migrate trust.json and actor-token-signing-key from the data volume
|
|
605
|
+
* (old location: /data/.vellum/protected/) to the gateway security volume
|
|
606
|
+
* (new location: /gateway-security/).
|
|
607
|
+
*
|
|
608
|
+
* Uses a temporary busybox container that mounts both volumes. The migration
|
|
609
|
+
* is idempotent: it only copies a file when the source exists on the data
|
|
610
|
+
* volume and the destination does not yet exist on the gateway security volume.
|
|
611
|
+
*
|
|
612
|
+
* Skips migration entirely if the data volume does not exist (new instances
|
|
613
|
+
* no longer create one).
|
|
614
|
+
*/
|
|
615
|
+
export async function migrateGatewaySecurityFiles(
|
|
616
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
617
|
+
log: (msg: string) => void,
|
|
618
|
+
): Promise<void> {
|
|
619
|
+
// New instances don't have a data volume — nothing to migrate.
|
|
620
|
+
if (!(await dockerVolumeExists(res.dataVolume))) {
|
|
621
|
+
log(" No data volume found — skipping gateway security migration.");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const migrationContainer = `${res.gatewayContainer}-migration`;
|
|
626
|
+
const filesToMigrate = ["trust.json", "actor-token-signing-key"];
|
|
627
|
+
|
|
628
|
+
// Remove any leftover migration container from a previous interrupted run.
|
|
629
|
+
try {
|
|
630
|
+
await exec("docker", ["rm", "-f", migrationContainer]);
|
|
631
|
+
} catch {
|
|
632
|
+
// container may not exist
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
for (const fileName of filesToMigrate) {
|
|
636
|
+
const src = `/data/.vellum/protected/${fileName}`;
|
|
637
|
+
const dst = `/gateway-security/${fileName}`;
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
// Run a busybox container that checks source exists and destination
|
|
641
|
+
// does not, then copies. The shell exits 0 whether or not a copy
|
|
642
|
+
// happens, so the migration is always safe to re-run.
|
|
643
|
+
await exec("docker", [
|
|
644
|
+
"run",
|
|
645
|
+
"--rm",
|
|
646
|
+
"--name",
|
|
647
|
+
migrationContainer,
|
|
648
|
+
"-v",
|
|
649
|
+
`${res.dataVolume}:/data:ro`,
|
|
650
|
+
"-v",
|
|
651
|
+
`${res.gatewaySecurityVolume}:/gateway-security`,
|
|
652
|
+
"busybox",
|
|
653
|
+
"sh",
|
|
654
|
+
"-c",
|
|
655
|
+
`if [ -f "${src}" ] && [ ! -f "${dst}" ]; then cp "${src}" "${dst}" && echo "migrated"; else echo "skipped"; fi`,
|
|
656
|
+
]);
|
|
657
|
+
log(` ${fileName}: checked`);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
// Non-fatal — log and continue. The gateway will create fresh files
|
|
660
|
+
// if they don't exist.
|
|
661
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
662
|
+
log(` ${fileName}: migration failed (${message}), continuing...`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Migrate keys.enc and store.key from the data volume
|
|
669
|
+
* (old location: /data/.vellum/protected/) to the CES security volume
|
|
670
|
+
* (new location: /ces-security/).
|
|
671
|
+
*
|
|
672
|
+
* Uses a temporary busybox container that mounts both volumes. The migration
|
|
673
|
+
* is idempotent: it only copies a file when the source exists on the data
|
|
674
|
+
* volume and the destination does not yet exist on the CES security volume.
|
|
675
|
+
* Migrated files are chowned to 1001:1001 (the CES service user).
|
|
676
|
+
*
|
|
677
|
+
* Skips migration entirely if the data volume does not exist (new instances
|
|
678
|
+
* no longer create one).
|
|
679
|
+
*/
|
|
680
|
+
export async function migrateCesSecurityFiles(
|
|
681
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
682
|
+
log: (msg: string) => void,
|
|
683
|
+
): Promise<void> {
|
|
684
|
+
// New instances don't have a data volume — nothing to migrate.
|
|
685
|
+
if (!(await dockerVolumeExists(res.dataVolume))) {
|
|
686
|
+
log(" No data volume found — skipping CES security migration.");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const migrationContainer = `${res.cesContainer}-migration`;
|
|
691
|
+
const filesToMigrate = ["keys.enc", "store.key"];
|
|
692
|
+
|
|
693
|
+
// Remove any leftover migration container from a previous interrupted run.
|
|
694
|
+
try {
|
|
695
|
+
await exec("docker", ["rm", "-f", migrationContainer]);
|
|
696
|
+
} catch {
|
|
697
|
+
// container may not exist
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
for (const fileName of filesToMigrate) {
|
|
701
|
+
const src = `/data/.vellum/protected/${fileName}`;
|
|
702
|
+
const dst = `/ces-security/${fileName}`;
|
|
703
|
+
|
|
704
|
+
try {
|
|
705
|
+
// Run a busybox container that checks source exists and destination
|
|
706
|
+
// does not, then copies and sets ownership. The shell exits 0 whether
|
|
707
|
+
// or not a copy happens, so the migration is always safe to re-run.
|
|
708
|
+
await exec("docker", [
|
|
709
|
+
"run",
|
|
710
|
+
"--rm",
|
|
711
|
+
"--name",
|
|
712
|
+
migrationContainer,
|
|
713
|
+
"-v",
|
|
714
|
+
`${res.dataVolume}:/data:ro`,
|
|
715
|
+
"-v",
|
|
716
|
+
`${res.cesSecurityVolume}:/ces-security`,
|
|
717
|
+
"busybox",
|
|
718
|
+
"sh",
|
|
719
|
+
"-c",
|
|
720
|
+
`if [ -f "${src}" ] && [ ! -f "${dst}" ]; then cp "${src}" "${dst}" && chown 1001:1001 "${dst}" && echo "migrated"; else echo "skipped"; fi`,
|
|
721
|
+
]);
|
|
722
|
+
log(` ${fileName}: checked`);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
// Non-fatal — log and continue. The CES will start without
|
|
725
|
+
// credentials if they don't exist.
|
|
726
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
727
|
+
log(` ${fileName}: migration failed (${message}), continuing...`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
538
732
|
/** The order in which services must be started. */
|
|
539
733
|
export const SERVICE_START_ORDER: ServiceName[] = [
|
|
540
734
|
"assistant",
|
|
@@ -545,6 +739,8 @@ export const SERVICE_START_ORDER: ServiceName[] = [
|
|
|
545
739
|
/** Start all three containers in dependency order. */
|
|
546
740
|
export async function startContainers(
|
|
547
741
|
opts: {
|
|
742
|
+
bootstrapSecret?: string;
|
|
743
|
+
cesServiceToken?: string;
|
|
548
744
|
extraAssistantEnv?: Record<string, string>;
|
|
549
745
|
gatewayPort: number;
|
|
550
746
|
imageTags: Record<ServiceName, string>;
|
|
@@ -569,15 +765,46 @@ export async function stopContainers(
|
|
|
569
765
|
await removeContainer(res.assistantContainer);
|
|
570
766
|
}
|
|
571
767
|
|
|
768
|
+
/**
|
|
769
|
+
* Remove the signing-key-bootstrap lockfile from the gateway security volume.
|
|
770
|
+
* This allows the daemon to re-fetch the signing key from the gateway on the
|
|
771
|
+
* next startup — necessary during upgrades where the gateway may generate a
|
|
772
|
+
* new key.
|
|
773
|
+
*/
|
|
774
|
+
export async function clearSigningKeyBootstrapLock(
|
|
775
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
776
|
+
): Promise<void> {
|
|
777
|
+
await exec("docker", [
|
|
778
|
+
"run",
|
|
779
|
+
"--rm",
|
|
780
|
+
"-v",
|
|
781
|
+
`${res.gatewaySecurityVolume}:/gateway-security`,
|
|
782
|
+
"busybox",
|
|
783
|
+
"rm",
|
|
784
|
+
"-f",
|
|
785
|
+
"/gateway-security/signing-key-bootstrap.lock",
|
|
786
|
+
]);
|
|
787
|
+
}
|
|
788
|
+
|
|
572
789
|
/** Stop containers without removing them (preserves state for `docker start`). */
|
|
573
790
|
export async function sleepContainers(
|
|
574
791
|
res: ReturnType<typeof dockerResourceNames>,
|
|
575
792
|
): Promise<void> {
|
|
576
|
-
for (const container of [
|
|
793
|
+
for (const container of [
|
|
794
|
+
res.cesContainer,
|
|
795
|
+
res.gatewayContainer,
|
|
796
|
+
res.assistantContainer,
|
|
797
|
+
]) {
|
|
577
798
|
try {
|
|
578
799
|
await exec("docker", ["stop", container]);
|
|
579
|
-
} catch {
|
|
580
|
-
|
|
800
|
+
} catch (err) {
|
|
801
|
+
const msg =
|
|
802
|
+
err instanceof Error ? err.message.toLowerCase() : String(err);
|
|
803
|
+
if (msg.includes("no such container") || msg.includes("is not running")) {
|
|
804
|
+
// container doesn't exist or already stopped — expected, skip
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
throw err;
|
|
581
808
|
}
|
|
582
809
|
}
|
|
583
810
|
}
|
|
@@ -586,7 +813,11 @@ export async function sleepContainers(
|
|
|
586
813
|
export async function wakeContainers(
|
|
587
814
|
res: ReturnType<typeof dockerResourceNames>,
|
|
588
815
|
): Promise<void> {
|
|
589
|
-
for (const container of [
|
|
816
|
+
for (const container of [
|
|
817
|
+
res.assistantContainer,
|
|
818
|
+
res.gatewayContainer,
|
|
819
|
+
res.cesContainer,
|
|
820
|
+
]) {
|
|
590
821
|
await exec("docker", ["start", container]);
|
|
591
822
|
}
|
|
592
823
|
}
|
|
@@ -867,10 +1098,45 @@ export async function hatchDocker(
|
|
|
867
1098
|
|
|
868
1099
|
log("📁 Creating network and volumes...");
|
|
869
1100
|
await exec("docker", ["network", "create", res.network]);
|
|
870
|
-
await exec("docker", ["volume", "create", res.dataVolume]);
|
|
871
1101
|
await exec("docker", ["volume", "create", res.socketVolume]);
|
|
1102
|
+
await exec("docker", ["volume", "create", res.workspaceVolume]);
|
|
1103
|
+
await exec("docker", ["volume", "create", res.cesSecurityVolume]);
|
|
1104
|
+
await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
|
|
1105
|
+
|
|
1106
|
+
// Set workspace volume ownership so non-root containers (UID 1001) can write.
|
|
1107
|
+
await exec("docker", [
|
|
1108
|
+
"run",
|
|
1109
|
+
"--rm",
|
|
1110
|
+
"-v",
|
|
1111
|
+
`${res.workspaceVolume}:/workspace`,
|
|
1112
|
+
"busybox",
|
|
1113
|
+
"chown",
|
|
1114
|
+
"1001:1001",
|
|
1115
|
+
"/workspace",
|
|
1116
|
+
]);
|
|
1117
|
+
|
|
1118
|
+
// Clear any stale signing-key bootstrap lockfile so the daemon can
|
|
1119
|
+
// fetch the key from the gateway on first startup.
|
|
1120
|
+
await exec("docker", [
|
|
1121
|
+
"run",
|
|
1122
|
+
"--rm",
|
|
1123
|
+
"-v",
|
|
1124
|
+
`${res.gatewaySecurityVolume}:/gateway-security`,
|
|
1125
|
+
"busybox",
|
|
1126
|
+
"rm",
|
|
1127
|
+
"-f",
|
|
1128
|
+
"/gateway-security/signing-key-bootstrap.lock",
|
|
1129
|
+
]);
|
|
1130
|
+
|
|
1131
|
+
const cesServiceToken = randomBytes(32).toString("hex");
|
|
1132
|
+
const bootstrapSecret = randomBytes(32).toString("hex");
|
|
1133
|
+
saveBootstrapSecret(instanceName, bootstrapSecret);
|
|
1134
|
+
await startContainers(
|
|
1135
|
+
{ bootstrapSecret, cesServiceToken, gatewayPort, imageTags, instanceName, res },
|
|
1136
|
+
log,
|
|
1137
|
+
);
|
|
872
1138
|
|
|
873
|
-
|
|
1139
|
+
const imageDigests = await captureImageRefs(res);
|
|
874
1140
|
|
|
875
1141
|
const runtimeUrl = `http://localhost:${gatewayPort}`;
|
|
876
1142
|
const dockerEntry: AssistantEntry = {
|
|
@@ -880,6 +1146,16 @@ export async function hatchDocker(
|
|
|
880
1146
|
species,
|
|
881
1147
|
hatchedAt: new Date().toISOString(),
|
|
882
1148
|
volume: res.dataVolume,
|
|
1149
|
+
serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
|
|
1150
|
+
containerInfo: {
|
|
1151
|
+
assistantImage: imageTags.assistant,
|
|
1152
|
+
gatewayImage: imageTags.gateway,
|
|
1153
|
+
cesImage: imageTags["credential-executor"],
|
|
1154
|
+
assistantDigest: imageDigests?.assistant,
|
|
1155
|
+
gatewayDigest: imageDigests?.gateway,
|
|
1156
|
+
cesDigest: imageDigests?.["credential-executor"],
|
|
1157
|
+
networkName: res.network,
|
|
1158
|
+
},
|
|
883
1159
|
};
|
|
884
1160
|
saveAssistantEntry(dockerEntry);
|
|
885
1161
|
setActiveAssistant(instanceName);
|
|
@@ -1035,7 +1311,9 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1035
1311
|
// Log periodically so the user knows we're still trying
|
|
1036
1312
|
const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
|
|
1037
1313
|
log(
|
|
1038
|
-
`Guardian token lease: attempt failed after ${elapsed}s (${
|
|
1314
|
+
`Guardian token lease: attempt failed after ${elapsed}s (${
|
|
1315
|
+
lastLeaseError.split("\n")[0]
|
|
1316
|
+
}), retrying...`,
|
|
1039
1317
|
);
|
|
1040
1318
|
}
|
|
1041
1319
|
await new Promise((r) => setTimeout(r, 2000));
|
|
@@ -1043,7 +1321,10 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1043
1321
|
|
|
1044
1322
|
if (!leaseSuccess) {
|
|
1045
1323
|
log(
|
|
1046
|
-
`\u26a0\ufe0f Guardian token lease: FAILED after ${(
|
|
1324
|
+
`\u26a0\ufe0f Guardian token lease: FAILED after ${(
|
|
1325
|
+
(Date.now() - leaseStart) /
|
|
1326
|
+
1000
|
|
1327
|
+
).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
|
|
1047
1328
|
);
|
|
1048
1329
|
}
|
|
1049
1330
|
|
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
|
);
|
|
@@ -42,6 +42,46 @@ function getPersistedDeviceIdPath(): string {
|
|
|
42
42
|
return join(getXdgConfigHome(), "vellum", "device-id");
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function getBootstrapSecretPath(assistantId: string): string {
|
|
46
|
+
return join(
|
|
47
|
+
getXdgConfigHome(),
|
|
48
|
+
"vellum",
|
|
49
|
+
"assistants",
|
|
50
|
+
assistantId,
|
|
51
|
+
"bootstrap-secret",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load a previously saved bootstrap secret for the given assistant.
|
|
57
|
+
* Returns null if the file does not exist or is unreadable.
|
|
58
|
+
*/
|
|
59
|
+
export function loadBootstrapSecret(assistantId: string): string | null {
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(getBootstrapSecretPath(assistantId), "utf-8").trim();
|
|
62
|
+
return raw.length > 0 ? raw : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Persist a bootstrap secret for the given assistant so that the desktop
|
|
70
|
+
* client and upgrade/rollback paths can retrieve it later.
|
|
71
|
+
*/
|
|
72
|
+
export function saveBootstrapSecret(
|
|
73
|
+
assistantId: string,
|
|
74
|
+
secret: string,
|
|
75
|
+
): void {
|
|
76
|
+
const path = getBootstrapSecretPath(assistantId);
|
|
77
|
+
const dir = dirname(path);
|
|
78
|
+
if (!existsSync(dir)) {
|
|
79
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
80
|
+
}
|
|
81
|
+
writeFileSync(path, secret + "\n", { mode: 0o600 });
|
|
82
|
+
chmodSync(path, 0o600);
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
function hashWithSalt(input: string): string {
|
|
46
86
|
return createHash("sha256")
|
|
47
87
|
.update(input + DEVICE_ID_SALT)
|
|
@@ -168,9 +208,14 @@ export async function leaseGuardianToken(
|
|
|
168
208
|
assistantId: string,
|
|
169
209
|
): Promise<GuardianTokenData> {
|
|
170
210
|
const deviceId = computeDeviceId();
|
|
211
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
212
|
+
const bootstrapSecret = loadBootstrapSecret(assistantId);
|
|
213
|
+
if (bootstrapSecret) {
|
|
214
|
+
headers["x-bootstrap-secret"] = bootstrapSecret;
|
|
215
|
+
}
|
|
171
216
|
const response = await fetch(`${gatewayUrl}/v1/guardian/init`, {
|
|
172
217
|
method: "POST",
|
|
173
|
-
headers
|
|
218
|
+
headers,
|
|
174
219
|
body: JSON.stringify({ platform: "cli", deviceId }),
|
|
175
220
|
});
|
|
176
221
|
|
package/src/lib/health-check.ts
CHANGED
|
@@ -3,11 +3,13 @@ export const HEALTH_CHECK_TIMEOUT_MS = 1500;
|
|
|
3
3
|
interface HealthResponse {
|
|
4
4
|
status: string;
|
|
5
5
|
message?: string;
|
|
6
|
+
version?: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export interface HealthCheckResult {
|
|
9
10
|
status: string;
|
|
10
11
|
detail: string | null;
|
|
12
|
+
version?: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export async function checkManagedHealth(
|
|
@@ -63,6 +65,7 @@ export async function checkManagedHealth(
|
|
|
63
65
|
return {
|
|
64
66
|
status,
|
|
65
67
|
detail: status !== "healthy" ? (data.message ?? null) : null,
|
|
68
|
+
version: data.version,
|
|
66
69
|
};
|
|
67
70
|
} catch (error) {
|
|
68
71
|
const status =
|
|
@@ -108,6 +111,7 @@ export async function checkHealth(
|
|
|
108
111
|
return {
|
|
109
112
|
status,
|
|
110
113
|
detail: status !== "healthy" ? (data.message ?? null) : null,
|
|
114
|
+
version: data.version,
|
|
111
115
|
};
|
|
112
116
|
} catch (error) {
|
|
113
117
|
const status =
|
|
@@ -60,14 +60,15 @@ interface OrganizationListResponse {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export async function fetchOrganizationId(token: string): Promise<string> {
|
|
63
|
-
const
|
|
63
|
+
const platformUrl = getPlatformUrl();
|
|
64
|
+
const url = `${platformUrl}/v1/organizations/`;
|
|
64
65
|
const response = await fetch(url, {
|
|
65
66
|
headers: { "X-Session-Token": token },
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
if (!response.ok) {
|
|
69
70
|
throw new Error(
|
|
70
|
-
`Failed to fetch organizations (${response.status}). Try logging in again.`,
|
|
71
|
+
`Failed to fetch organizations from ${platformUrl} (${response.status}). Try logging in again.`,
|
|
71
72
|
);
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a version string into { major, minor, patch } components.
|
|
3
|
+
* Handles optional `v` prefix (e.g., "v1.2.3" or "1.2.3").
|
|
4
|
+
* Returns null if the string cannot be parsed as semver.
|
|
5
|
+
*/
|
|
6
|
+
export function parseVersion(
|
|
7
|
+
version: string,
|
|
8
|
+
): { major: number; minor: number; patch: number } | null {
|
|
9
|
+
const stripped = version.replace(/^[vV]/, "");
|
|
10
|
+
const segments = stripped.split(".");
|
|
11
|
+
|
|
12
|
+
if (segments.length < 2) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const major = parseInt(segments[0], 10);
|
|
17
|
+
const minor = parseInt(segments[1], 10);
|
|
18
|
+
const patch = segments.length >= 3 ? parseInt(segments[2], 10) : 0;
|
|
19
|
+
|
|
20
|
+
if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { major, minor, patch };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check whether two version strings are compatible.
|
|
29
|
+
* Compatibility requires matching major AND minor versions.
|
|
30
|
+
* Patch differences are allowed.
|
|
31
|
+
* Returns false if either version cannot be parsed.
|
|
32
|
+
*/
|
|
33
|
+
export function isVersionCompatible(
|
|
34
|
+
clientVersion: string,
|
|
35
|
+
serviceGroupVersion: string,
|
|
36
|
+
): boolean {
|
|
37
|
+
const a = parseVersion(clientVersion);
|
|
38
|
+
const b = parseVersion(serviceGroupVersion);
|
|
39
|
+
|
|
40
|
+
if (a === null || b === null) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return a.major === b.major && a.minor === b.minor;
|
|
45
|
+
}
|