@vellumai/cli 0.7.0 → 0.7.2

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.
Files changed (54) hide show
  1. package/AGENTS.md +3 -11
  2. package/README.md +49 -0
  3. package/bun.lock +0 -15
  4. package/package.json +1 -6
  5. package/src/__tests__/backup.test.ts +591 -0
  6. package/src/__tests__/config-utils.test.ts +35 -48
  7. package/src/__tests__/teleport.test.ts +597 -37
  8. package/src/commands/backup.ts +149 -70
  9. package/src/commands/client.ts +56 -14
  10. package/src/commands/events.ts +3 -0
  11. package/src/commands/exec.ts +34 -12
  12. package/src/commands/hatch.ts +3 -7
  13. package/src/commands/login.ts +15 -33
  14. package/src/commands/logs.ts +2 -7
  15. package/src/commands/ps.ts +41 -6
  16. package/src/commands/restore.ts +32 -47
  17. package/src/commands/setup.ts +38 -73
  18. package/src/commands/ssh.ts +2 -5
  19. package/src/commands/teleport.ts +148 -34
  20. package/src/commands/tunnel.ts +2 -7
  21. package/src/commands/upgrade.ts +114 -7
  22. package/src/commands/wake.ts +5 -16
  23. package/src/components/DefaultMainScreen.tsx +65 -129
  24. package/src/index.ts +2 -13
  25. package/src/lib/__tests__/docker.test.ts +50 -32
  26. package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
  28. package/src/lib/__tests__/runtime-url.test.ts +125 -0
  29. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  30. package/src/lib/assistant-client.ts +18 -26
  31. package/src/lib/assistant-config.ts +34 -41
  32. package/src/lib/backup-ops.ts +43 -17
  33. package/src/lib/cli-error.ts +1 -0
  34. package/src/lib/client-identity.ts +1 -1
  35. package/src/lib/config-utils.ts +1 -97
  36. package/src/lib/docker-statefulset.ts +381 -0
  37. package/src/lib/docker.ts +8 -247
  38. package/src/lib/guardian-token.ts +56 -6
  39. package/src/lib/hatch-local.ts +3 -26
  40. package/src/lib/job-polling.ts +1 -1
  41. package/src/lib/local-runtime-client.ts +162 -28
  42. package/src/lib/local.ts +35 -64
  43. package/src/lib/ngrok.ts +36 -26
  44. package/src/lib/platform-client.ts +97 -221
  45. package/src/lib/platform-releases.ts +23 -0
  46. package/src/lib/retire-local.ts +2 -2
  47. package/src/lib/runtime-url.ts +52 -0
  48. package/src/lib/sync-cloud-assistants.ts +126 -0
  49. package/src/lib/terminal-client.ts +6 -1
  50. package/src/lib/terminal-session.ts +127 -48
  51. package/src/lib/tui-log.ts +60 -0
  52. package/src/lib/upgrade-lifecycle.ts +65 -0
  53. package/src/lib/xdg-log.ts +10 -4
  54. package/src/commands/pair.ts +0 -212
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 { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
16
+ import { buildServiceRunArgs } from "./docker-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";
@@ -363,7 +363,6 @@ export function dockerResourceNames(instanceName: string) {
363
363
  assistantIpcVolume: `${instanceName}-assistant-ipc`,
364
364
  cesContainer: `${instanceName}-credential-executor`,
365
365
  cesSecurityVolume: `${instanceName}-ces-sec`,
366
- dockerdDataVolume: `${instanceName}-dockerd-data`,
367
366
  gatewayContainer: `${instanceName}-gateway`,
368
367
  gatewayIpcVolume: `${instanceName}-gateway-ipc`,
369
368
  gatewaySecurityVolume: `${instanceName}-gateway-sec`,
@@ -426,7 +425,6 @@ export async function retireDocker(name: string): Promise<void> {
426
425
  res.workspaceVolume,
427
426
  res.cesSecurityVolume,
428
427
  res.gatewaySecurityVolume,
429
- res.dockerdDataVolume,
430
428
  ]) {
431
429
  try {
432
430
  await exec("docker", ["volume", "rm", vol]);
@@ -563,10 +561,11 @@ async function buildAllImages(
563
561
  }
564
562
 
565
563
  /**
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.
564
+ * Build `docker run` argument arrays for each service in the StatefulSet.
565
+ *
566
+ * Delegates to `buildServiceRunArgs` from `docker-statefulset.ts`, which owns
567
+ * the declarative container / volume / env spec. Signature preserved for
568
+ * backward compatibility with callers throughout this file.
570
569
  */
571
570
  export function serviceDockerRunArgs(opts: {
572
571
  signingKey?: string;
@@ -579,235 +578,8 @@ export function serviceDockerRunArgs(opts: {
579
578
  instanceName: string;
580
579
  res: ReturnType<typeof dockerResourceNames>;
581
580
  }): 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
- `VELLUM_ASSISTANT_NAME=${instanceName}`,
666
- "-e",
667
- "VELLUM_CLOUD=docker",
668
- "-e",
669
- "RUNTIME_HTTP_HOST=0.0.0.0",
670
- "-e",
671
- "VELLUM_WORKSPACE_DIR=/workspace",
672
- "-e",
673
- "VELLUM_BACKUP_DIR=/workspace/.backups",
674
- "-e",
675
- "VELLUM_BACKUP_KEY_PATH=/workspace/.backup.key",
676
- "-e",
677
- "CES_CREDENTIAL_URL=http://localhost:8090",
678
- "-e",
679
- `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
680
- "-e",
681
- "GATEWAY_IPC_SOCKET_DIR=/run/gateway-ipc",
682
- "-e",
683
- "ASSISTANT_IPC_SOCKET_DIR=/run/assistant-ipc",
684
- ];
685
- if (defaultWorkspaceConfigPath) {
686
- const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
687
- args.push(
688
- "-v",
689
- `${defaultWorkspaceConfigPath}:${containerPath}:ro`,
690
- "-e",
691
- `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH=${containerPath}`,
692
- );
693
- }
694
- if (cesServiceToken) {
695
- args.push("-e", `CES_SERVICE_TOKEN=${cesServiceToken}`);
696
- }
697
- if (opts.signingKey) {
698
- args.push("-e", `ACTOR_TOKEN_SIGNING_KEY=${opts.signingKey}`);
699
- }
700
- if (opts.bootstrapSecret) {
701
- // Mirror the secret into the assistant container so the runtime's
702
- // guardian-bootstrap handler can validate the x-bootstrap-secret
703
- // header forwarded by the gateway. Without this, the published
704
- // runtime port would expose an unauthenticated token-minting
705
- // endpoint reachable from the host bypassing the gateway's gate.
706
- args.push("-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`);
707
- }
708
- for (const envVar of [
709
- ...Object.values(PROVIDER_ENV_VAR_NAMES),
710
- "VELLUM_ENVIRONMENT",
711
- "VELLUM_PLATFORM_URL",
712
- ]) {
713
- if (process.env[envVar]) {
714
- args.push("-e", `${envVar}=${process.env[envVar]}`);
715
- }
716
- }
717
- if (extraAssistantEnv) {
718
- for (const [key, value] of Object.entries(extraAssistantEnv)) {
719
- args.push("-e", `${key}=${value}`);
720
- }
721
- }
722
- const avatarDevice = resolveAvatarDevicePath();
723
- if (existsSync(avatarDevice)) {
724
- args.push(
725
- "--device",
726
- `${avatarDevice}:${avatarDevice}`,
727
- "-e",
728
- `${AVATAR_DEVICE_ENV_VAR}=${avatarDevice}`,
729
- );
730
- }
731
- args.push(imageTags.assistant);
732
- return args;
733
- },
734
- gateway: () => [
735
- "run",
736
- "--init",
737
- "-d",
738
- "--name",
739
- res.gatewayContainer,
740
- `--network=container:${res.assistantContainer}`,
741
- "-v",
742
- `${res.workspaceVolume}:/workspace`,
743
- "-v",
744
- `${res.gatewaySecurityVolume}:/gateway-security`,
745
- "-v",
746
- `${res.assistantIpcVolume}:/run/assistant-ipc`,
747
- "-v",
748
- `${res.gatewayIpcVolume}:/run/gateway-ipc`,
749
- "-e",
750
- "VELLUM_WORKSPACE_DIR=/workspace",
751
- "-e",
752
- "GATEWAY_SECURITY_DIR=/gateway-security",
753
- "-e",
754
- `GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
755
- "-e",
756
- "ASSISTANT_HOST=localhost",
757
- "-e",
758
- `RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
759
- "-e",
760
- "RUNTIME_PROXY_ENABLED=true",
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
- };
581
+ const avatarDevice = resolveAvatarDevicePath();
582
+ return buildServiceRunArgs({ ...opts, avatarDevicePath: avatarDevice });
811
583
  }
812
584
 
813
585
  /** The order in which services must be started. */
@@ -832,16 +604,6 @@ export async function startContainers(
832
604
  },
833
605
  log: (msg: string) => void,
834
606
  ): Promise<void> {
835
- // Ensure the inner dockerd's data volume exists before mounting it.
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
607
  const runArgs = serviceDockerRunArgs(opts);
846
608
  for (const service of SERVICE_START_ORDER) {
847
609
  log(`🚀 Starting ${service} container...`);
@@ -1254,7 +1016,6 @@ export async function hatchDocker(
1254
1016
  await exec("docker", ["volume", "create", res.workspaceVolume]);
1255
1017
  await exec("docker", ["volume", "create", res.cesSecurityVolume]);
1256
1018
  await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
1257
- await exec("docker", ["volume", "create", res.dockerdDataVolume]);
1258
1019
 
1259
1020
  // Set volume ownership so non-root containers (UID 1001) can write.
1260
1021
  await exec("docker", [
@@ -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
- accessTokenExpiresAt: string;
23
+ /** ISO date string or epoch-ms number as returned by the gateway. */
24
+ accessTokenExpiresAt: string | number;
24
25
  refreshToken: string;
25
- refreshTokenExpiresAt: string;
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 (matches PairingQRCodeSheet.computeHostId)
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.parse(parsed.refreshTokenExpiresAt);
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)) {
@@ -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
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
240
- process.env.BASE_DATA_DIR = resources.instanceDir;
241
- try {
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();
@@ -107,7 +107,7 @@ function isTransientPollError(err: unknown): boolean {
107
107
  *
108
108
  * Transient errors raised by `poll()` (5xx, network hiccups, rate-limits) are
109
109
  * retried up to `maxTransientErrors` times before the last error propagates,
110
- * matching the pre-rewrite `platformPollExportStatus` loop's behavior so a
110
+ * matching the pre-rewrite migration-export polling loop's behavior so a
111
111
  * single flaky poll doesn't abort a migration that may still be running.
112
112
  */
113
113
  export async function pollJobUntilDone(