@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/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 [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,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 { extraAssistantEnv, gatewayPort, imageTags, instanceName, res } = opts;
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.dataVolume}:/data`,
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
- for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
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.dataVolume}:/data`,
536
+ `${res.workspaceVolume}:/workspace`,
537
+ "-v",
538
+ `${res.gatewaySecurityVolume}:/gateway-security`,
505
539
  "-e",
506
- "BASE_DATA_DIR=/data",
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.dataVolume}:/data:ro`,
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
- "CES_ASSISTANT_DATA_MOUNT=/data",
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 [res.cesContainer, res.gatewayContainer, res.assistantContainer]) {
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
- // container may not exist or already stopped
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 [res.assistantContainer, res.gatewayContainer, res.cesContainer]) {
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
- await startContainers({ gatewayPort, imageTags, instanceName, res }, log);
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 (${lastLeaseError.split("\n")[0]}), retrying...`,
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 ${((Date.now() - leaseStart) / 1000).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
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 { 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
  );
@@ -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: { "Content-Type": "application/json" },
218
+ headers,
174
219
  body: JSON.stringify({ platform: "cli", deviceId }),
175
220
  });
176
221
 
@@ -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 url = `${getPlatformUrl()}/v1/organizations/`;
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
+ }