@vellumai/cli 0.7.1 → 0.7.3
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 +3 -11
- package/bun.lock +0 -15
- package/package.json +1 -6
- package/src/__tests__/backup.test.ts +121 -5
- package/src/__tests__/teleport.test.ts +515 -10
- package/src/commands/backup.ts +35 -2
- package/src/commands/client.ts +90 -7
- package/src/commands/exec.ts +13 -4
- package/src/commands/hatch.ts +1 -1
- package/src/commands/login.ts +11 -0
- package/src/commands/restore.ts +7 -1
- package/src/commands/rollback.ts +1 -1
- package/src/commands/setup.ts +38 -73
- package/src/commands/teleport.ts +122 -12
- package/src/commands/upgrade.ts +8 -2
- package/src/commands/wake.ts +5 -16
- package/src/components/DefaultMainScreen.tsx +42 -130
- package/src/index.ts +1 -7
- package/src/lib/__tests__/docker.test.ts +53 -35
- package/src/lib/__tests__/local-runtime-client.test.ts +186 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +235 -0
- package/src/lib/__tests__/runtime-url.test.ts +39 -1
- package/src/lib/assistant-client.ts +13 -5
- package/src/lib/assistant-config.ts +0 -25
- package/src/lib/backup-ops.ts +43 -17
- package/src/lib/client-identity.ts +9 -5
- package/src/lib/docker.ts +6 -267
- package/src/lib/environments/paths.ts +20 -0
- package/src/lib/guardian-token.ts +56 -6
- package/src/lib/hatch-local.ts +3 -26
- package/src/lib/local-runtime-client.ts +82 -1
- package/src/lib/local.ts +9 -7
- package/src/lib/ngrok.ts +36 -26
- package/src/lib/platform-client.ts +100 -1
- package/src/lib/retire-local.ts +2 -2
- package/src/lib/runtime-url.ts +22 -0
- package/src/lib/statefulset.ts +375 -0
- package/src/lib/upgrade-lifecycle.ts +97 -1
- package/src/commands/pair.ts +0 -212
|
@@ -11,7 +11,7 @@ import { randomUUID } from "crypto";
|
|
|
11
11
|
import { homedir } from "os";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
|
|
14
|
-
const CLI_INTERFACE_ID = "cli";
|
|
14
|
+
export const CLI_INTERFACE_ID = "cli";
|
|
15
15
|
|
|
16
16
|
let cached: string | null = null;
|
|
17
17
|
|
|
@@ -56,12 +56,16 @@ export function getClientId(): string {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Headers that identify this CLI client to the assistant daemon.
|
|
59
|
-
* Attach to
|
|
60
|
-
*
|
|
59
|
+
* Attach to all requests so the ClientRegistry can track connected
|
|
60
|
+
* clients and their capabilities.
|
|
61
|
+
*
|
|
62
|
+
* @param interfaceId - Override the interface ID (default: "cli").
|
|
61
63
|
*/
|
|
62
|
-
export function getClientRegistrationHeaders(
|
|
64
|
+
export function getClientRegistrationHeaders(
|
|
65
|
+
interfaceId: string = CLI_INTERFACE_ID,
|
|
66
|
+
): Record<string, string> {
|
|
63
67
|
return {
|
|
64
68
|
"X-Vellum-Client-Id": getClientId(),
|
|
65
|
-
"X-Vellum-Interface-Id":
|
|
69
|
+
"X-Vellum-Interface-Id": interfaceId,
|
|
66
70
|
};
|
|
67
71
|
}
|
package/src/lib/docker.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./assistant-config";
|
|
14
14
|
import type { AssistantEntry } from "./assistant-config";
|
|
15
15
|
import { writeInitialConfig } from "./config-utils";
|
|
16
|
-
import {
|
|
16
|
+
import { buildServiceRunArgs } from "./statefulset.js";
|
|
17
17
|
import type { Species } from "./constants";
|
|
18
18
|
import { getDefaultPorts } from "./environments/paths.js";
|
|
19
19
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
@@ -39,9 +39,8 @@ export const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
|
|
|
39
39
|
gateway: `${DOCKERHUB_ORG}/vellum-gateway`,
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
/** Internal ports exposed by each service's Dockerfile. */
|
|
43
|
-
export
|
|
44
|
-
export const GATEWAY_INTERNAL_PORT = 7830;
|
|
42
|
+
/** Internal ports exposed by each service's Dockerfile. Re-exported from environments/paths.ts. */
|
|
43
|
+
export { ASSISTANT_INTERNAL_PORT, GATEWAY_INTERNAL_PORT } from "./environments/paths.js";
|
|
45
44
|
|
|
46
45
|
/** Max time to wait for the assistant container to emit the readiness sentinel. */
|
|
47
46
|
export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
@@ -363,7 +362,6 @@ export function dockerResourceNames(instanceName: string) {
|
|
|
363
362
|
assistantIpcVolume: `${instanceName}-assistant-ipc`,
|
|
364
363
|
cesContainer: `${instanceName}-credential-executor`,
|
|
365
364
|
cesSecurityVolume: `${instanceName}-ces-sec`,
|
|
366
|
-
dockerdDataVolume: `${instanceName}-dockerd-data`,
|
|
367
365
|
gatewayContainer: `${instanceName}-gateway`,
|
|
368
366
|
gatewayIpcVolume: `${instanceName}-gateway-ipc`,
|
|
369
367
|
gatewaySecurityVolume: `${instanceName}-gateway-sec`,
|
|
@@ -426,7 +424,6 @@ export async function retireDocker(name: string): Promise<void> {
|
|
|
426
424
|
res.workspaceVolume,
|
|
427
425
|
res.cesSecurityVolume,
|
|
428
426
|
res.gatewaySecurityVolume,
|
|
429
|
-
res.dockerdDataVolume,
|
|
430
427
|
]) {
|
|
431
428
|
try {
|
|
432
429
|
await exec("docker", ["volume", "rm", vol]);
|
|
@@ -562,254 +559,6 @@ async function buildAllImages(
|
|
|
562
559
|
);
|
|
563
560
|
}
|
|
564
561
|
|
|
565
|
-
/**
|
|
566
|
-
* Returns a function that builds the `docker run` arguments for a given
|
|
567
|
-
* service. All three containers share a network namespace via
|
|
568
|
-
* `--network=container:` so inter-service traffic is over localhost,
|
|
569
|
-
* matching the platform's Kubernetes pod topology.
|
|
570
|
-
*/
|
|
571
|
-
export function serviceDockerRunArgs(opts: {
|
|
572
|
-
signingKey?: string;
|
|
573
|
-
bootstrapSecret?: string;
|
|
574
|
-
cesServiceToken?: string;
|
|
575
|
-
extraAssistantEnv?: Record<string, string>;
|
|
576
|
-
gatewayPort: number;
|
|
577
|
-
imageTags: Record<ServiceName, string>;
|
|
578
|
-
defaultWorkspaceConfigPath?: string;
|
|
579
|
-
instanceName: string;
|
|
580
|
-
res: ReturnType<typeof dockerResourceNames>;
|
|
581
|
-
}): Record<ServiceName, () => string[]> {
|
|
582
|
-
const {
|
|
583
|
-
cesServiceToken,
|
|
584
|
-
defaultWorkspaceConfigPath,
|
|
585
|
-
extraAssistantEnv,
|
|
586
|
-
gatewayPort,
|
|
587
|
-
imageTags,
|
|
588
|
-
instanceName,
|
|
589
|
-
res,
|
|
590
|
-
} = opts;
|
|
591
|
-
return {
|
|
592
|
-
assistant: () => {
|
|
593
|
-
// Run the assistant container in Docker-in-Docker (DinD) mode: the
|
|
594
|
-
// container runs its own `dockerd` so the Meet subsystem can spawn
|
|
595
|
-
// sibling meet-bot containers without needing access to the host's
|
|
596
|
-
// Docker engine. This requires:
|
|
597
|
-
// - `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` so the inner dockerd can
|
|
598
|
-
// configure cgroups, overlay mounts, network namespaces, and
|
|
599
|
-
// iptables. We deliberately avoid `--privileged` (which grants the
|
|
600
|
-
// full host capability set and access to every host device node)
|
|
601
|
-
// to shrink the escape surface from any code running inside the
|
|
602
|
-
// assistant container. See the "Security tradeoff for Docker mode"
|
|
603
|
-
// note in AGENTS.md.
|
|
604
|
-
// - `seccomp=unconfined` + `apparmor=unconfined` because Docker's
|
|
605
|
-
// default seccomp profile blocks syscalls dockerd needs (e.g.
|
|
606
|
-
// certain clone/unshare and pivot_root flags) and the default
|
|
607
|
-
// AppArmor profile on Debian/Ubuntu hosts denies the mount
|
|
608
|
-
// operations dockerd performs while launching bot containers. On
|
|
609
|
-
// hosts where these LSMs are inactive, the options are no-ops.
|
|
610
|
-
// - A dedicated named volume mounted at `/var/lib/docker` so the
|
|
611
|
-
// inner Docker image cache and container state survive restarts of
|
|
612
|
-
// the assistant container.
|
|
613
|
-
// The host's `/var/run/docker.sock` is intentionally NOT mounted — all
|
|
614
|
-
// Meet-bot spawning happens against the inner dockerd.
|
|
615
|
-
const args: string[] = [
|
|
616
|
-
"run",
|
|
617
|
-
"--init",
|
|
618
|
-
"-d",
|
|
619
|
-
"--cap-add",
|
|
620
|
-
"SYS_ADMIN",
|
|
621
|
-
"--cap-add",
|
|
622
|
-
"NET_ADMIN",
|
|
623
|
-
"--security-opt",
|
|
624
|
-
"seccomp=unconfined",
|
|
625
|
-
"--security-opt",
|
|
626
|
-
"apparmor=unconfined",
|
|
627
|
-
"--name",
|
|
628
|
-
res.assistantContainer,
|
|
629
|
-
`--network=${res.network}`,
|
|
630
|
-
"-p",
|
|
631
|
-
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
632
|
-
// Published so the Meet subsystem's sibling bot containers can reach
|
|
633
|
-
// the daemon's internal HTTP API at host.docker.internal:<port>.
|
|
634
|
-
//
|
|
635
|
-
// Published on all host interfaces (no `127.0.0.1:` prefix) because on
|
|
636
|
-
// vanilla Linux Docker, `host.docker.internal:host-gateway` resolves
|
|
637
|
-
// to the Docker bridge gateway IP (e.g. 172.17.0.1), not loopback.
|
|
638
|
-
// Packets from sibling containers arrive at the host's bridge
|
|
639
|
-
// interface, and an iptables DNAT rule keyed on dest=127.0.0.1 would
|
|
640
|
-
// not match — causing connection refused. Docker Desktop (macOS/
|
|
641
|
-
// Windows) still works because its VM proxy forwards to the same
|
|
642
|
-
// published port regardless of the binding address.
|
|
643
|
-
//
|
|
644
|
-
// Security tradeoff: the daemon HTTP API is now reachable from the
|
|
645
|
-
// host's LAN (any device that can hit the host IP on this port).
|
|
646
|
-
// This matches the gateway port's existing posture and is acceptable
|
|
647
|
-
// for single-user self-hosted Docker mode per the Phase 1.8 security
|
|
648
|
-
// note. Managed/multi-tenant deployments are out of scope and would
|
|
649
|
-
// require a different design.
|
|
650
|
-
"-p",
|
|
651
|
-
`${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`,
|
|
652
|
-
"-v",
|
|
653
|
-
`${res.workspaceVolume}:/workspace`,
|
|
654
|
-
"-v",
|
|
655
|
-
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
656
|
-
"-v",
|
|
657
|
-
`${res.assistantIpcVolume}:/run/assistant-ipc`,
|
|
658
|
-
"-v",
|
|
659
|
-
`${res.gatewayIpcVolume}:/run/gateway-ipc`,
|
|
660
|
-
"-v",
|
|
661
|
-
`${res.dockerdDataVolume}:/var/lib/docker`,
|
|
662
|
-
"-e",
|
|
663
|
-
"IS_CONTAINERIZED=true",
|
|
664
|
-
"-e",
|
|
665
|
-
"DEBUG_STDOUT_LOGS=1",
|
|
666
|
-
"-e",
|
|
667
|
-
`VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
668
|
-
"-e",
|
|
669
|
-
"VELLUM_CLOUD=docker",
|
|
670
|
-
"-e",
|
|
671
|
-
"RUNTIME_HTTP_HOST=0.0.0.0",
|
|
672
|
-
"-e",
|
|
673
|
-
"VELLUM_WORKSPACE_DIR=/workspace",
|
|
674
|
-
"-e",
|
|
675
|
-
"VELLUM_BACKUP_DIR=/workspace/.backups",
|
|
676
|
-
"-e",
|
|
677
|
-
"VELLUM_BACKUP_KEY_PATH=/workspace/.backup.key",
|
|
678
|
-
"-e",
|
|
679
|
-
"CES_CREDENTIAL_URL=http://localhost:8090",
|
|
680
|
-
"-e",
|
|
681
|
-
`GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
|
|
682
|
-
"-e",
|
|
683
|
-
"GATEWAY_IPC_SOCKET_DIR=/run/gateway-ipc",
|
|
684
|
-
"-e",
|
|
685
|
-
"ASSISTANT_IPC_SOCKET_DIR=/run/assistant-ipc",
|
|
686
|
-
];
|
|
687
|
-
if (defaultWorkspaceConfigPath) {
|
|
688
|
-
const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
|
|
689
|
-
args.push(
|
|
690
|
-
"-v",
|
|
691
|
-
`${defaultWorkspaceConfigPath}:${containerPath}:ro`,
|
|
692
|
-
"-e",
|
|
693
|
-
`VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH=${containerPath}`,
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
if (cesServiceToken) {
|
|
697
|
-
args.push("-e", `CES_SERVICE_TOKEN=${cesServiceToken}`);
|
|
698
|
-
}
|
|
699
|
-
if (opts.signingKey) {
|
|
700
|
-
args.push("-e", `ACTOR_TOKEN_SIGNING_KEY=${opts.signingKey}`);
|
|
701
|
-
}
|
|
702
|
-
if (opts.bootstrapSecret) {
|
|
703
|
-
// Mirror the secret into the assistant container so the runtime's
|
|
704
|
-
// guardian-bootstrap handler can validate the x-bootstrap-secret
|
|
705
|
-
// header forwarded by the gateway. Without this, the published
|
|
706
|
-
// runtime port would expose an unauthenticated token-minting
|
|
707
|
-
// endpoint reachable from the host bypassing the gateway's gate.
|
|
708
|
-
args.push("-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`);
|
|
709
|
-
}
|
|
710
|
-
for (const envVar of [
|
|
711
|
-
...Object.values(PROVIDER_ENV_VAR_NAMES),
|
|
712
|
-
"VELLUM_ENVIRONMENT",
|
|
713
|
-
"VELLUM_PLATFORM_URL",
|
|
714
|
-
]) {
|
|
715
|
-
if (process.env[envVar]) {
|
|
716
|
-
args.push("-e", `${envVar}=${process.env[envVar]}`);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
if (extraAssistantEnv) {
|
|
720
|
-
for (const [key, value] of Object.entries(extraAssistantEnv)) {
|
|
721
|
-
args.push("-e", `${key}=${value}`);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
const avatarDevice = resolveAvatarDevicePath();
|
|
725
|
-
if (existsSync(avatarDevice)) {
|
|
726
|
-
args.push(
|
|
727
|
-
"--device",
|
|
728
|
-
`${avatarDevice}:${avatarDevice}`,
|
|
729
|
-
"-e",
|
|
730
|
-
`${AVATAR_DEVICE_ENV_VAR}=${avatarDevice}`,
|
|
731
|
-
);
|
|
732
|
-
}
|
|
733
|
-
args.push(imageTags.assistant);
|
|
734
|
-
return args;
|
|
735
|
-
},
|
|
736
|
-
gateway: () => [
|
|
737
|
-
"run",
|
|
738
|
-
"--init",
|
|
739
|
-
"-d",
|
|
740
|
-
"--name",
|
|
741
|
-
res.gatewayContainer,
|
|
742
|
-
`--network=container:${res.assistantContainer}`,
|
|
743
|
-
"-v",
|
|
744
|
-
`${res.workspaceVolume}:/workspace`,
|
|
745
|
-
"-v",
|
|
746
|
-
`${res.gatewaySecurityVolume}:/gateway-security`,
|
|
747
|
-
"-v",
|
|
748
|
-
`${res.assistantIpcVolume}:/run/assistant-ipc`,
|
|
749
|
-
"-v",
|
|
750
|
-
`${res.gatewayIpcVolume}:/run/gateway-ipc`,
|
|
751
|
-
"-e",
|
|
752
|
-
"VELLUM_WORKSPACE_DIR=/workspace",
|
|
753
|
-
"-e",
|
|
754
|
-
"GATEWAY_SECURITY_DIR=/gateway-security",
|
|
755
|
-
"-e",
|
|
756
|
-
`GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
|
|
757
|
-
"-e",
|
|
758
|
-
"ASSISTANT_HOST=localhost",
|
|
759
|
-
"-e",
|
|
760
|
-
`RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
|
|
761
|
-
"-e",
|
|
762
|
-
"CES_CREDENTIAL_URL=http://localhost:8090",
|
|
763
|
-
"-e",
|
|
764
|
-
"GATEWAY_IPC_SOCKET_DIR=/run/gateway-ipc",
|
|
765
|
-
"-e",
|
|
766
|
-
"ASSISTANT_IPC_SOCKET_DIR=/run/assistant-ipc",
|
|
767
|
-
...(cesServiceToken
|
|
768
|
-
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
769
|
-
: []),
|
|
770
|
-
...(opts.signingKey
|
|
771
|
-
? ["-e", `ACTOR_TOKEN_SIGNING_KEY=${opts.signingKey}`]
|
|
772
|
-
: []),
|
|
773
|
-
...(opts.bootstrapSecret
|
|
774
|
-
? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
|
|
775
|
-
: []),
|
|
776
|
-
...(process.env.VELLUM_ENVIRONMENT
|
|
777
|
-
? ["-e", `VELLUM_ENVIRONMENT=${process.env.VELLUM_ENVIRONMENT}`]
|
|
778
|
-
: []),
|
|
779
|
-
...(process.env.VELLUM_PLATFORM_URL
|
|
780
|
-
? ["-e", `VELLUM_PLATFORM_URL=${process.env.VELLUM_PLATFORM_URL}`]
|
|
781
|
-
: []),
|
|
782
|
-
imageTags.gateway,
|
|
783
|
-
],
|
|
784
|
-
"credential-executor": () => [
|
|
785
|
-
"run",
|
|
786
|
-
"--init",
|
|
787
|
-
"-d",
|
|
788
|
-
"--name",
|
|
789
|
-
res.cesContainer,
|
|
790
|
-
`--network=container:${res.assistantContainer}`,
|
|
791
|
-
"-v",
|
|
792
|
-
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
793
|
-
"-v",
|
|
794
|
-
`${res.workspaceVolume}:/workspace:ro`,
|
|
795
|
-
"-v",
|
|
796
|
-
`${res.cesSecurityVolume}:/ces-security`,
|
|
797
|
-
"-e",
|
|
798
|
-
"CES_MODE=managed",
|
|
799
|
-
"-e",
|
|
800
|
-
"VELLUM_WORKSPACE_DIR=/workspace",
|
|
801
|
-
"-e",
|
|
802
|
-
"CES_BOOTSTRAP_SOCKET_DIR=/run/ces-bootstrap",
|
|
803
|
-
"-e",
|
|
804
|
-
"CREDENTIAL_SECURITY_DIR=/ces-security",
|
|
805
|
-
...(cesServiceToken
|
|
806
|
-
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
807
|
-
: []),
|
|
808
|
-
imageTags["credential-executor"],
|
|
809
|
-
],
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
|
|
813
562
|
/** The order in which services must be started. */
|
|
814
563
|
export const SERVICE_START_ORDER: ServiceName[] = [
|
|
815
564
|
"assistant",
|
|
@@ -832,17 +581,7 @@ export async function startContainers(
|
|
|
832
581
|
},
|
|
833
582
|
log: (msg: string) => void,
|
|
834
583
|
): Promise<void> {
|
|
835
|
-
|
|
836
|
-
// For instances hatched on Phase 1.10+, this is created in hatchDocker and
|
|
837
|
-
// is a no-op here. For instances that pre-date Phase 1.10 (DinD) and are
|
|
838
|
-
// upgrading in place, Docker would otherwise auto-create the volume on
|
|
839
|
-
// first `-v` mount without our standard ownership/labeling. Creating it
|
|
840
|
-
// explicitly keeps volume provenance consistent across fresh and upgraded
|
|
841
|
-
// instances. `docker volume create` is idempotent for an existing volume
|
|
842
|
-
// of the same name, so this is safe to run on every start.
|
|
843
|
-
await exec("docker", ["volume", "create", opts.res.dockerdDataVolume]);
|
|
844
|
-
|
|
845
|
-
const runArgs = serviceDockerRunArgs(opts);
|
|
584
|
+
const runArgs = buildServiceRunArgs({ ...opts, avatarDevicePath: resolveAvatarDevicePath() });
|
|
846
585
|
for (const service of SERVICE_START_ORDER) {
|
|
847
586
|
log(`🚀 Starting ${service} container...`);
|
|
848
587
|
await exec("docker", runArgs[service]());
|
|
@@ -1020,7 +759,7 @@ function startFileWatcher(opts: {
|
|
|
1020
759
|
let rebuilding = false;
|
|
1021
760
|
|
|
1022
761
|
const configs = serviceImageConfigs(repoRoot, imageTags);
|
|
1023
|
-
const runArgs =
|
|
762
|
+
const runArgs = buildServiceRunArgs({
|
|
1024
763
|
signingKey: opts.signingKey,
|
|
1025
764
|
bootstrapSecret: opts.bootstrapSecret,
|
|
1026
765
|
cesServiceToken: opts.cesServiceToken,
|
|
@@ -1028,6 +767,7 @@ function startFileWatcher(opts: {
|
|
|
1028
767
|
imageTags,
|
|
1029
768
|
instanceName,
|
|
1030
769
|
res,
|
|
770
|
+
avatarDevicePath: resolveAvatarDevicePath(),
|
|
1031
771
|
});
|
|
1032
772
|
const containerForService: Record<ServiceName, string> = {
|
|
1033
773
|
assistant: res.assistantContainer,
|
|
@@ -1254,7 +994,6 @@ export async function hatchDocker(
|
|
|
1254
994
|
await exec("docker", ["volume", "create", res.workspaceVolume]);
|
|
1255
995
|
await exec("docker", ["volume", "create", res.cesSecurityVolume]);
|
|
1256
996
|
await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
|
|
1257
|
-
await exec("docker", ["volume", "create", res.dockerdDataVolume]);
|
|
1258
997
|
|
|
1259
998
|
// Set volume ownership so non-root containers (UID 1001) can write.
|
|
1260
999
|
await exec("docker", [
|
|
@@ -98,6 +98,26 @@ export function getDefaultPorts(env: EnvironmentDefinition): PortMap {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Runtime state directory for an environment (upgrade logs, etc.).
|
|
103
|
+
* Production uses `~/.local/share/vellum/`; non-production environments
|
|
104
|
+
* use `~/.local/share/vellum-<env>/`.
|
|
105
|
+
*/
|
|
106
|
+
export function getStateDir(env: EnvironmentDefinition): string {
|
|
107
|
+
if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
|
|
108
|
+
return join(xdgDataHome(), "vellum");
|
|
109
|
+
}
|
|
110
|
+
return join(xdgDataHome(), `vellum-${env.name}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Named port constants derived from `DEFAULT_PORTS`.
|
|
115
|
+
* These are the ports the assistant and gateway services bind to *inside*
|
|
116
|
+
* their container (or process). They are stable across environments.
|
|
117
|
+
*/
|
|
118
|
+
export const ASSISTANT_INTERNAL_PORT = DEFAULT_PORTS.daemon;
|
|
119
|
+
export const GATEWAY_INTERNAL_PORT = DEFAULT_PORTS.gateway;
|
|
120
|
+
|
|
101
121
|
function xdgDataHome(): string {
|
|
102
122
|
return (
|
|
103
123
|
process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share")
|
|
@@ -20,9 +20,11 @@ const DEVICE_ID_SALT = "vellum-assistant-host-id";
|
|
|
20
20
|
export interface GuardianTokenData {
|
|
21
21
|
guardianPrincipalId: string;
|
|
22
22
|
accessToken: string;
|
|
23
|
-
|
|
23
|
+
/** ISO date string or epoch-ms number as returned by the gateway. */
|
|
24
|
+
accessTokenExpiresAt: string | number;
|
|
24
25
|
refreshToken: string;
|
|
25
|
-
|
|
26
|
+
/** ISO date string or epoch-ms number as returned by the gateway. */
|
|
27
|
+
refreshTokenExpiresAt: string | number;
|
|
26
28
|
refreshAfter: string;
|
|
27
29
|
isNew: boolean;
|
|
28
30
|
deviceId: string;
|
|
@@ -104,7 +106,7 @@ export function getOrCreatePersistedDeviceId(): string {
|
|
|
104
106
|
/**
|
|
105
107
|
* Compute a stable device identifier matching the native client conventions.
|
|
106
108
|
*
|
|
107
|
-
* - macOS: SHA-256 of IOPlatformUUID + salt
|
|
109
|
+
* - macOS: SHA-256 of IOPlatformUUID + salt
|
|
108
110
|
* - Linux: SHA-256 of /etc/machine-id + salt
|
|
109
111
|
* - Windows: SHA-256 of HKLM MachineGuid + salt
|
|
110
112
|
* - Fallback: persisted random UUID in XDG config
|
|
@@ -158,6 +160,54 @@ export function saveGuardianToken(
|
|
|
158
160
|
chmodSync(tokenPath, 0o600);
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Call POST /v1/guardian/refresh on the remote gateway to obtain a new
|
|
165
|
+
* access token using an existing (possibly expired) access token for auth.
|
|
166
|
+
* Returns the refreshed token data (persisted locally), or null if the
|
|
167
|
+
* refresh fails (e.g. no stored token, or refresh token itself is expired).
|
|
168
|
+
*/
|
|
169
|
+
export async function refreshGuardianToken(
|
|
170
|
+
gatewayUrl: string,
|
|
171
|
+
assistantId: string,
|
|
172
|
+
): Promise<GuardianTokenData | null> {
|
|
173
|
+
const tokenData = loadGuardianToken(assistantId);
|
|
174
|
+
if (!tokenData) return null;
|
|
175
|
+
|
|
176
|
+
// Gateway persists expiresAt as epoch-ms numbers; Date.parse("1234567890000")
|
|
177
|
+
// returns NaN. new Date() accepts both ISO strings and epoch-ms numbers.
|
|
178
|
+
const refreshExpiry = new Date(tokenData.refreshTokenExpiresAt).getTime();
|
|
179
|
+
if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now()) return null;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: {
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
Authorization: `Bearer ${tokenData.accessToken}`,
|
|
187
|
+
},
|
|
188
|
+
body: JSON.stringify({ refreshToken: tokenData.refreshToken }),
|
|
189
|
+
});
|
|
190
|
+
if (!response.ok) return null;
|
|
191
|
+
|
|
192
|
+
const json = (await response.json()) as Record<string, unknown>;
|
|
193
|
+
const refreshed: GuardianTokenData = {
|
|
194
|
+
guardianPrincipalId: (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
|
|
195
|
+
accessToken: json.accessToken as string,
|
|
196
|
+
accessTokenExpiresAt: (json.accessTokenExpiresAt as string | number) ?? tokenData.accessTokenExpiresAt,
|
|
197
|
+
refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
|
|
198
|
+
refreshTokenExpiresAt: (json.refreshTokenExpiresAt as string | number) ?? tokenData.refreshTokenExpiresAt,
|
|
199
|
+
refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
|
|
200
|
+
isNew: false,
|
|
201
|
+
deviceId: tokenData.deviceId,
|
|
202
|
+
leasedAt: new Date().toISOString(),
|
|
203
|
+
};
|
|
204
|
+
saveGuardianToken(assistantId, refreshed);
|
|
205
|
+
return refreshed;
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
161
211
|
/**
|
|
162
212
|
* Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
|
|
163
213
|
* credential pair. The returned tokens are persisted locally under
|
|
@@ -190,9 +240,9 @@ export async function leaseGuardianToken(
|
|
|
190
240
|
const tokenData: GuardianTokenData = {
|
|
191
241
|
guardianPrincipalId: json.guardianPrincipalId as string,
|
|
192
242
|
accessToken: json.accessToken as string,
|
|
193
|
-
accessTokenExpiresAt: json.accessTokenExpiresAt as string,
|
|
243
|
+
accessTokenExpiresAt: json.accessTokenExpiresAt as string | number,
|
|
194
244
|
refreshToken: json.refreshToken as string,
|
|
195
|
-
refreshTokenExpiresAt: json.refreshTokenExpiresAt as string,
|
|
245
|
+
refreshTokenExpiresAt: json.refreshTokenExpiresAt as string | number,
|
|
196
246
|
refreshAfter: json.refreshAfter as string,
|
|
197
247
|
isNew: json.isNew as boolean,
|
|
198
248
|
deviceId,
|
|
@@ -248,7 +298,7 @@ export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
|
|
|
248
298
|
try {
|
|
249
299
|
const raw = readFileSync(sibling);
|
|
250
300
|
const parsed = JSON.parse(raw.toString("utf-8")) as GuardianTokenData;
|
|
251
|
-
const refreshExpiry = Date
|
|
301
|
+
const refreshExpiry = new Date(parsed.refreshTokenExpiresAt).getTime();
|
|
252
302
|
if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
|
|
253
303
|
const dir = dirname(destPath);
|
|
254
304
|
if (!existsSync(dir)) {
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
readlinkSync,
|
|
6
6
|
symlinkSync,
|
|
7
7
|
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
8
|
appendFileSync,
|
|
10
9
|
readFileSync,
|
|
11
10
|
} from "fs";
|
|
@@ -20,7 +19,6 @@ import {
|
|
|
20
19
|
findAssistantByName,
|
|
21
20
|
saveAssistantEntry,
|
|
22
21
|
setActiveAssistant,
|
|
23
|
-
syncConfigToLockfile,
|
|
24
22
|
} from "./assistant-config.js";
|
|
25
23
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
26
24
|
import type { Species } from "./constants.js";
|
|
@@ -31,7 +29,6 @@ import {
|
|
|
31
29
|
startGateway,
|
|
32
30
|
stopLocalProcesses,
|
|
33
31
|
} from "./local.js";
|
|
34
|
-
import { maybeStartNgrokTunnel } from "./ngrok.js";
|
|
35
32
|
|
|
36
33
|
import { generateInstanceName } from "./random-name.js";
|
|
37
34
|
import { leaseGuardianToken } from "./guardian-token.js";
|
|
@@ -223,9 +220,6 @@ export async function hatchLocal(
|
|
|
223
220
|
}
|
|
224
221
|
|
|
225
222
|
// Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
|
|
226
|
-
// Set BASE_DATA_DIR so ngrok reads the correct instance config. Keep the
|
|
227
|
-
// lockfile save/sync inside the same scope so syncConfigToLockfile() reads
|
|
228
|
-
// this instance's workspace/config.json rather than a stale default path.
|
|
229
223
|
const localEntry: AssistantEntry = {
|
|
230
224
|
assistantId: instanceName,
|
|
231
225
|
runtimeUrl,
|
|
@@ -236,26 +230,9 @@ export async function hatchLocal(
|
|
|
236
230
|
resources: { ...resources, signingKey },
|
|
237
231
|
};
|
|
238
232
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
243
|
-
if (ngrokChild?.pid) {
|
|
244
|
-
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
245
|
-
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
emitProgress(6, 6, "Saving configuration...");
|
|
249
|
-
saveAssistantEntry(localEntry);
|
|
250
|
-
setActiveAssistant(instanceName);
|
|
251
|
-
syncConfigToLockfile();
|
|
252
|
-
} finally {
|
|
253
|
-
if (prevBaseDataDir !== undefined) {
|
|
254
|
-
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
255
|
-
} else {
|
|
256
|
-
delete process.env.BASE_DATA_DIR;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
233
|
+
emitProgress(6, 6, "Saving configuration...");
|
|
234
|
+
saveAssistantEntry(localEntry);
|
|
235
|
+
setActiveAssistant(instanceName);
|
|
259
236
|
|
|
260
237
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
261
238
|
installCLISymlink();
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
2
2
|
import {
|
|
3
3
|
authHeaders,
|
|
4
|
+
invalidateOrgIdCache,
|
|
4
5
|
parseUnifiedJobStatus,
|
|
5
6
|
type UnifiedJobStatus,
|
|
6
7
|
} from "./platform-client.js";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
resolveRuntimeMigrationUrl,
|
|
10
|
+
resolveRuntimeUrl,
|
|
11
|
+
} from "./runtime-url.js";
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Thrown when the local runtime returns 409 for an export/import request
|
|
@@ -229,3 +233,80 @@ export async function localRuntimePollJobStatus(
|
|
|
229
233
|
>[0];
|
|
230
234
|
return parseUnifiedJobStatus(raw);
|
|
231
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The subset of `/v1/health` we care about. The runtime's full response
|
|
239
|
+
* includes additional fields (status, disk, memory, cpu, migrations, etc.)
|
|
240
|
+
* — we only model `version` here because that's all the CLI consumes today.
|
|
241
|
+
*/
|
|
242
|
+
export interface RuntimeIdentity {
|
|
243
|
+
version: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fetch the target runtime's APP_VERSION via `/v1/health`. Used by
|
|
248
|
+
* `vellum teleport` and `vellum backup` to stamp the exported bundle's
|
|
249
|
+
* `min_runtime_version` with the version of the runtime that actually
|
|
250
|
+
* produced it — which can diverge from the orchestrating CLI's version when
|
|
251
|
+
* the target was upgraded independently.
|
|
252
|
+
*
|
|
253
|
+
* GETs `/v1/health` (not `/v1/identity`) so the call works on freshly-
|
|
254
|
+
* hatched runtimes that haven't completed onboarding. The `/v1/identity`
|
|
255
|
+
* handler reads `IDENTITY.md` from the workspace and 404s if it's missing
|
|
256
|
+
* — and `IDENTITY.md` is only written during onboarding, not hatch. The
|
|
257
|
+
* `/v1/health` handler returns the same `version` field unconditionally
|
|
258
|
+
* (no filesystem reads), so it's safe to call against any running runtime.
|
|
259
|
+
*
|
|
260
|
+
* For local/docker assistants this GETs `{runtimeUrl}/v1/health` with
|
|
261
|
+
* guardian-token bearer auth. For platform-managed (cloud="vellum")
|
|
262
|
+
* assistants the URL is rewritten to the wildcard runtime proxy shape
|
|
263
|
+
* `{platformUrl}/v1/assistants/<assistantId>/health` and authenticated via
|
|
264
|
+
* the platform token.
|
|
265
|
+
*
|
|
266
|
+
* For the vellum target this is the FIRST network call in the
|
|
267
|
+
* teleport/backup export flow, so a stale `Vellum-Organization-Id` cache
|
|
268
|
+
* entry would surface as a hard abort before any retry-friendly call (like
|
|
269
|
+
* `platformRequestSignedUrl`) gets a chance to recover. Mirror that helper's
|
|
270
|
+
* one-shot 401-retry: invalidate the org-ID cache and retry once. Local /
|
|
271
|
+
* docker entries do not use the org-ID cache and are wrapped in
|
|
272
|
+
* `callRuntimeWithAuthRetry` by callers for guardian-token refresh, so the
|
|
273
|
+
* retry is intentionally vellum-only.
|
|
274
|
+
*
|
|
275
|
+
* The function name is intentionally retained ("identity-ish info about the
|
|
276
|
+
* runtime") even though the implementation now hits `/v1/health` — renaming
|
|
277
|
+
* would force changes in 4+ callsites for no behavioral benefit.
|
|
278
|
+
*
|
|
279
|
+
* Throws on non-2xx so callers can surface the failure (we never silently
|
|
280
|
+
* fall back — see teleport.ts call site).
|
|
281
|
+
*/
|
|
282
|
+
export async function localRuntimeIdentity(
|
|
283
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
284
|
+
token: string,
|
|
285
|
+
): Promise<RuntimeIdentity> {
|
|
286
|
+
const url = resolveRuntimeUrl(entry, "health");
|
|
287
|
+
const doRequest = async (): Promise<Response> =>
|
|
288
|
+
fetch(url, {
|
|
289
|
+
method: "GET",
|
|
290
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
let response = await doRequest();
|
|
294
|
+
if (response.status === 401 && entry.cloud === "vellum") {
|
|
295
|
+
// `entry.runtimeUrl` is the platform host for vellum-cloud entries
|
|
296
|
+
// (the wildcard runtime proxy lives there). Pass it as the cache key
|
|
297
|
+
// platformUrl so we invalidate the same entry that authHeaders cached.
|
|
298
|
+
invalidateOrgIdCache(token, entry.runtimeUrl);
|
|
299
|
+
response = await doRequest();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Failed to fetch runtime identity: ${response.status} ${response.statusText}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const body = (await response.json()) as { version?: unknown };
|
|
308
|
+
if (typeof body.version !== "string" || !body.version) {
|
|
309
|
+
throw new Error("Runtime identity response missing version");
|
|
310
|
+
}
|
|
311
|
+
return { version: body.version };
|
|
312
|
+
}
|