@vellumai/cli 0.8.10-staging.1 → 0.8.11-staging.1

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 (48) hide show
  1. package/AGENTS.md +2 -0
  2. package/node_modules/@vellumai/local-mode/src/config.ts +13 -0
  3. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +2 -2
  4. package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
  5. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +20 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +3 -0
  7. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +169 -0
  8. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -4
  9. package/package.json +1 -1
  10. package/src/__tests__/confirm.test.ts +85 -0
  11. package/src/__tests__/device-id.test.ts +167 -0
  12. package/src/__tests__/guardian-token.test.ts +79 -0
  13. package/src/__tests__/helpers/env.ts +19 -0
  14. package/src/__tests__/statefulset.test.ts +149 -0
  15. package/src/__tests__/upgrade-replay-env.test.ts +165 -0
  16. package/src/__tests__/wake.test.ts +68 -0
  17. package/src/commands/backup.ts +3 -2
  18. package/src/commands/client.ts +22 -5
  19. package/src/commands/confirm.ts +144 -0
  20. package/src/commands/connect.ts +1 -1
  21. package/src/commands/devices.ts +4 -3
  22. package/src/commands/hatch.ts +16 -1
  23. package/src/commands/pair.ts +3 -2
  24. package/src/commands/restore.ts +3 -2
  25. package/src/commands/retire.ts +2 -1
  26. package/src/commands/roadmap.ts +2 -1
  27. package/src/commands/rollback.ts +9 -37
  28. package/src/commands/unpair.ts +1 -1
  29. package/src/commands/upgrade.ts +13 -44
  30. package/src/commands/wake.ts +49 -1
  31. package/src/index.ts +11 -4
  32. package/src/lib/assistant-client.ts +3 -2
  33. package/src/lib/backup-ops.ts +5 -4
  34. package/src/lib/device-id.ts +85 -0
  35. package/src/lib/docker.ts +19 -3
  36. package/src/lib/guardian-token.ts +44 -8
  37. package/src/lib/hatch-local.ts +2 -1
  38. package/src/lib/health-check.ts +6 -4
  39. package/src/lib/http-client.ts +3 -1
  40. package/src/lib/local-runtime-client.ts +5 -4
  41. package/src/lib/local.ts +1 -0
  42. package/src/lib/loopback-fetch.ts +28 -0
  43. package/src/lib/ngrok.ts +2 -1
  44. package/src/lib/platform-client.ts +28 -21
  45. package/src/lib/platform-releases.ts +3 -2
  46. package/src/lib/statefulset.ts +43 -0
  47. package/src/lib/terminal-client.ts +6 -5
  48. package/src/lib/upgrade-lifecycle.ts +114 -53
@@ -262,6 +262,49 @@ export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
262
262
  avatarDevicePath?: string;
263
263
  }
264
264
 
265
+ interface BuilderManagedEnvKeys {
266
+ /** Always set by buildServiceRunArgs (spec static/secret entries, builder-computed extras, image-baked PATH). Never replay. */
267
+ always: ReadonlySet<string>;
268
+ /**
269
+ * Spec host-forwarded entries. `name` is the container-side env key (what
270
+ * docker inspect captures); `hostVar` is the host process.env variable
271
+ * buildServiceRunArgs reads. Exclude captured `name` from replay only when
272
+ * process.env[hostVar] is set.
273
+ */
274
+ hostForwarded: ReadonlyArray<{ name: string; hostVar: string }>;
275
+ }
276
+
277
+ /**
278
+ * Env var names that `buildServiceRunArgs` manages for a service, derived
279
+ * from the spec so future entries are picked up automatically.
280
+ */
281
+ export function getBuilderManagedEnvKeys(
282
+ service: ServiceName,
283
+ spec = DOCKER_STATEFUL_SET_SPEC,
284
+ ): BuilderManagedEnvKeys {
285
+ const container = spec.containers.find((c) => c.internalName === service);
286
+ if (!container) throw new Error(`docker-statefulset: unknown service "${service}"`);
287
+
288
+ const always = new Set<string>(["PATH"]);
289
+ const hostForwarded: Array<{ name: string; hostVar: string }> = [];
290
+ for (const entry of container.env) {
291
+ if (entry.kind === "host") {
292
+ hostForwarded.push({ name: entry.name, hostVar: entry.hostVar ?? entry.name });
293
+ } else {
294
+ always.add(entry.name);
295
+ }
296
+ }
297
+
298
+ // Builder-computed extras added outside the spec env arrays
299
+ if (service === "assistant") {
300
+ always.add("VELLUM_ASSISTANT_NAME");
301
+ always.add("GATEWAY_INTERNAL_URL");
302
+ always.add(AVATAR_DEVICE_ENV_VAR);
303
+ }
304
+
305
+ return { always, hostForwarded };
306
+ }
307
+
265
308
  function resolveVolume(
266
309
  spec: DockerStatefulSetSpec,
267
310
  instanceName: string,
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { authHeaders, getPlatformUrl } from "./platform-client.js";
10
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Create / Close
@@ -25,7 +26,7 @@ export async function createTerminalSession(
25
26
  if (service) {
26
27
  body.service = service;
27
28
  }
28
- const response = await fetch(
29
+ const response = await loopbackSafeFetch(
29
30
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/`,
30
31
  {
31
32
  method: "POST",
@@ -49,7 +50,7 @@ export async function closeTerminalSession(
49
50
  platformUrl?: string,
50
51
  ): Promise<void> {
51
52
  const baseUrl = platformUrl || getPlatformUrl();
52
- const response = await fetch(
53
+ const response = await loopbackSafeFetch(
53
54
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/`,
54
55
  {
55
56
  method: "DELETE",
@@ -76,7 +77,7 @@ export async function sendTerminalInput(
76
77
  platformUrl?: string,
77
78
  ): Promise<void> {
78
79
  const baseUrl = platformUrl || getPlatformUrl();
79
- const response = await fetch(
80
+ const response = await loopbackSafeFetch(
80
81
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/input/`,
81
82
  {
82
83
  method: "POST",
@@ -100,7 +101,7 @@ export async function resizeTerminalSession(
100
101
  platformUrl?: string,
101
102
  ): Promise<void> {
102
103
  const baseUrl = platformUrl || getPlatformUrl();
103
- const response = await fetch(
104
+ const response = await loopbackSafeFetch(
104
105
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/resize/`,
105
106
  {
106
107
  method: "POST",
@@ -137,7 +138,7 @@ export async function* subscribeTerminalEvents(
137
138
  signal?: AbortSignal,
138
139
  ): AsyncGenerator<TerminalOutputEvent> {
139
140
  const baseUrl = platformUrl || getPlatformUrl();
140
- const response = await fetch(
141
+ const response = await loopbackSafeFetch(
141
142
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/events/`,
142
143
  {
143
144
  headers: await authHeaders(token, platformUrl),
@@ -7,6 +7,7 @@ import type { AssistantEntry } from "./assistant-config.js";
7
7
  import { saveAssistantEntry } from "./assistant-config.js";
8
8
  import { createBackup, pruneOldBackups, restoreBackup } from "./backup-ops.js";
9
9
  import { emitCliError } from "./cli-error.js";
10
+ import { getOrCreateHostDeviceId } from "./device-id.js";
10
11
  import {
11
12
  captureImageRefs,
12
13
  DOCKER_READY_TIMEOUT_MS,
@@ -19,8 +20,14 @@ import { getStateDir } from "./environments/paths.js";
19
20
  import { getCurrentEnvironment } from "./environments/resolve.js";
20
21
  import { loadGuardianToken } from "./guardian-token.js";
21
22
  import { resolveImageRefs } from "./platform-releases.js";
23
+ import {
24
+ getBuilderManagedEnvKeys,
25
+ type DockerStatefulSetSpec,
26
+ type ServiceName,
27
+ } from "./statefulset.js";
22
28
  import { exec, execOutput } from "./step-runner.js";
23
29
  import { compareVersions } from "./version-compat.js";
30
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
24
31
 
25
32
  // ---------------------------------------------------------------------------
26
33
  // Failure log capture
@@ -141,20 +148,6 @@ export function buildUpgradeCommitMessage(options: {
141
148
  return lines.join("\n");
142
149
  }
143
150
 
144
- /**
145
- * Environment variable keys that are set by CLI run arguments and should
146
- * not be replayed from a captured container environment during upgrades
147
- * or rollbacks. Shared between upgrade.ts and rollback.ts.
148
- */
149
- export const CONTAINER_ENV_EXCLUDE_KEYS: ReadonlySet<string> = new Set([
150
- "CES_SERVICE_TOKEN",
151
- "GUARDIAN_BOOTSTRAP_SECRET",
152
- "VELLUM_ASSISTANT_NAME",
153
- "RUNTIME_HTTP_HOST",
154
- "PATH",
155
- "ACTOR_TOKEN_SIGNING_KEY",
156
- ]);
157
-
158
151
  /**
159
152
  * Capture environment variables from a running Docker container so they
160
153
  * can be replayed onto the replacement container after upgrade.
@@ -183,6 +176,96 @@ export async function captureContainerEnv(
183
176
  return captured;
184
177
  }
185
178
 
179
+ /**
180
+ * Filter a captured container env down to the entries safe to replay onto a
181
+ * replacement container.
182
+ *
183
+ * Drops every key `buildServiceRunArgs` sets itself (spec static/secret
184
+ * entries, builder-computed extras, PATH). Spec-managed secrets re-enter via
185
+ * the dedicated `DockerRunSecrets` path, so adding a secret to the spec
186
+ * automatically excludes it here. Spec host-forwarded keys are dropped only
187
+ * when the host variable is currently set, so fresh host values win over
188
+ * stale captured ones.
189
+ *
190
+ * Security contract: the returned env is memory-only — never persist it to
191
+ * disk, and log counts only, never values.
192
+ */
193
+ export function buildReplayEnv(
194
+ capturedEnv: Record<string, string>,
195
+ service: ServiceName,
196
+ spec?: DockerStatefulSetSpec,
197
+ ): Record<string, string> {
198
+ const { always, hostForwarded } = getBuilderManagedEnvKeys(service, spec);
199
+ const hostManaged = new Set(
200
+ hostForwarded.filter((h) => process.env[h.hostVar]).map((h) => h.name),
201
+ );
202
+ return Object.fromEntries(
203
+ Object.entries(capturedEnv).filter(
204
+ ([key]) => !always.has(key) && !hostManaged.has(key),
205
+ ),
206
+ );
207
+ }
208
+
209
+ /** Secrets and replay env derived from the outgoing containers. */
210
+ interface ReplayState {
211
+ bootstrapSecret: string | undefined;
212
+ cesServiceToken: string;
213
+ signingKey: string;
214
+ extraAssistantEnv: Record<string, string>;
215
+ extraGatewayEnv: Record<string, string>;
216
+ }
217
+
218
+ /**
219
+ * Derive the secrets and replay env for replacement containers from
220
+ * already-captured assistant/gateway envs. GUARDIAN_BOOTSTRAP_SECRET is only
221
+ * set on the gateway; CES_SERVICE_TOKEN and ACTOR_TOKEN_SIGNING_KEY fall back
222
+ * to fresh values for instances that predate them. VELLUM_DEVICE_ID is
223
+ * backfilled on the gateway from the host, and the assistant inherits the
224
+ * gateway's value (captured values win) so the pair always matches.
225
+ */
226
+ export function buildReplayState(
227
+ capturedEnv: Record<string, string>,
228
+ gatewayEnv: Record<string, string>,
229
+ ): ReplayState {
230
+ const extraGatewayEnv = buildReplayEnv(gatewayEnv, "gateway");
231
+ extraGatewayEnv.VELLUM_DEVICE_ID ??= getOrCreateHostDeviceId();
232
+
233
+ const extraAssistantEnv = buildReplayEnv(capturedEnv, "assistant");
234
+ extraAssistantEnv.VELLUM_DEVICE_ID ??= extraGatewayEnv.VELLUM_DEVICE_ID;
235
+
236
+ return {
237
+ bootstrapSecret: gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"],
238
+ cesServiceToken:
239
+ capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex"),
240
+ signingKey:
241
+ capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex"),
242
+ extraAssistantEnv,
243
+ extraGatewayEnv,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Capture the assistant and gateway container envs and derive the replay
249
+ * state for the replacement containers. Logs only the assistant env-var
250
+ * count (security contract on `buildReplayEnv`).
251
+ */
252
+ export async function captureReplayState(
253
+ res: Pick<
254
+ ReturnType<typeof dockerResourceNames>,
255
+ "assistantContainer" | "gatewayContainer"
256
+ >,
257
+ ): Promise<ReplayState> {
258
+ console.log("💾 Capturing existing container environment...");
259
+ const [capturedEnv, gatewayEnv] = await Promise.all([
260
+ captureContainerEnv(res.assistantContainer),
261
+ captureContainerEnv(res.gatewayContainer),
262
+ ]);
263
+ console.log(
264
+ ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
265
+ );
266
+ return buildReplayState(capturedEnv, gatewayEnv);
267
+ }
268
+
186
269
  /**
187
270
  * Best-effort fetch of the running service group version from the gateway
188
271
  * `/healthz` endpoint. Returns `undefined` when the endpoint is
@@ -192,7 +275,7 @@ export async function fetchCurrentVersion(
192
275
  runtimeUrl: string,
193
276
  ): Promise<string | undefined> {
194
277
  try {
195
- const resp = await fetch(`${runtimeUrl}/healthz`, {
278
+ const resp = await loopbackSafeFetch(`${runtimeUrl}/healthz`, {
196
279
  signal: AbortSignal.timeout(5000),
197
280
  });
198
281
  if (resp.ok) {
@@ -217,7 +300,7 @@ export async function fetchAssistantIngressUrl(
217
300
  ): Promise<string | undefined> {
218
301
  if (!bearerToken) return undefined;
219
302
  try {
220
- const resp = await fetch(`${runtimeUrl}/integrations/ingress/config`, {
303
+ const resp = await loopbackSafeFetch(`${runtimeUrl}/integrations/ingress/config`, {
221
304
  headers: { Authorization: `Bearer ${bearerToken}` },
222
305
  signal: AbortSignal.timeout(5000),
223
306
  });
@@ -259,7 +342,7 @@ export async function fetchPreviousVersion(
259
342
  try {
260
343
  const { getPlatformUrl } = await import("./platform-client.js");
261
344
  const platformUrl = getPlatformUrl();
262
- const resp = await fetch(`${platformUrl}/v1/releases/?stable=true`, {
345
+ const resp = await loopbackSafeFetch(`${platformUrl}/v1/releases/?stable=true`, {
263
346
  signal: AbortSignal.timeout(10_000),
264
347
  });
265
348
  if (!resp.ok) return undefined;
@@ -291,7 +374,7 @@ export async function waitForReady(runtimeUrl: string): Promise<boolean> {
291
374
 
292
375
  while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
293
376
  try {
294
- const resp = await fetch(readyUrl, {
377
+ const resp = await loopbackSafeFetch(readyUrl, {
295
378
  signal: AbortSignal.timeout(5000),
296
379
  });
297
380
  if (resp.ok) {
@@ -337,7 +420,7 @@ export async function broadcastUpgradeEvent(
337
420
  if (token?.accessToken) {
338
421
  headers["Authorization"] = `Bearer ${token.accessToken}`;
339
422
  }
340
- await fetch(`${gatewayUrl}/v1/admin/upgrade-broadcast`, {
423
+ await loopbackSafeFetch(`${gatewayUrl}/v1/admin/upgrade-broadcast`, {
341
424
  method: "POST",
342
425
  headers,
343
426
  body: JSON.stringify(event),
@@ -366,7 +449,7 @@ export async function commitWorkspaceViaGateway(
366
449
  if (token?.accessToken) {
367
450
  headers["Authorization"] = `Bearer ${token.accessToken}`;
368
451
  }
369
- await fetch(`${gatewayUrl}/v1/admin/workspace-commit`, {
452
+ await loopbackSafeFetch(`${gatewayUrl}/v1/admin/workspace-commit`, {
370
453
  method: "POST",
371
454
  headers,
372
455
  body: JSON.stringify({ message }),
@@ -409,7 +492,7 @@ export async function rollbackMigrations(
409
492
  body.targetWorkspaceMigrationId = targetWorkspaceMigrationId;
410
493
  if (rollbackToRegistryCeiling) body.rollbackToRegistryCeiling = true;
411
494
 
412
- const resp = await fetch(`${gatewayUrl}/v1/admin/rollback-migrations`, {
495
+ const resp = await loopbackSafeFetch(`${gatewayUrl}/v1/admin/rollback-migrations`, {
413
496
  method: "POST",
414
497
  headers,
415
498
  body: JSON.stringify(body),
@@ -490,7 +573,7 @@ export async function performDockerRollback(
490
573
  lastWorkspaceMigrationId?: string;
491
574
  } = {};
492
575
  try {
493
- const healthResp = await fetch(
576
+ const healthResp = await loopbackSafeFetch(
494
577
  `${entry.runtimeUrl}/healthz?include=migrations`,
495
578
  { signal: AbortSignal.timeout(5000) },
496
579
  );
@@ -581,37 +664,13 @@ export async function performDockerRollback(
581
664
  console.warn("⚠️ Pre-rollback backup failed (continuing with rollback)\n");
582
665
  }
583
666
 
584
- // Capture container env, extract secrets
585
- console.log("💾 Capturing existing container environment...");
586
- const capturedEnv = await captureContainerEnv(res.assistantContainer);
587
- console.log(
588
- ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
589
- );
590
-
591
- // Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
592
- // set on gateway, not assistant) so it persists across container restarts.
593
- const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
594
- const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
595
-
596
- const cesServiceToken =
597
- capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
598
-
599
- const signingKey =
600
- capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
601
-
602
- // Build extra env vars, excluding keys managed by buildServiceRunArgs
603
- const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
604
- for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
605
- if (process.env[envVar]) {
606
- envKeysSetByRunArgs.add(envVar);
607
- }
608
- }
609
- const extraAssistantEnv: Record<string, string> = {};
610
- for (const [key, value] of Object.entries(capturedEnv)) {
611
- if (!envKeysSetByRunArgs.has(key)) {
612
- extraAssistantEnv[key] = value;
613
- }
614
- }
667
+ const {
668
+ bootstrapSecret,
669
+ cesServiceToken,
670
+ signingKey,
671
+ extraAssistantEnv,
672
+ extraGatewayEnv,
673
+ } = await captureReplayState(res);
615
674
 
616
675
  // Parse gateway port from entry's runtimeUrl
617
676
  let gatewayPort = GATEWAY_INTERNAL_PORT;
@@ -684,6 +743,7 @@ export async function performDockerRollback(
684
743
  bootstrapSecret,
685
744
  cesServiceToken,
686
745
  extraAssistantEnv,
746
+ extraGatewayEnv,
687
747
  gatewayPort,
688
748
  imageTags: targetImageTags,
689
749
  instanceName,
@@ -801,6 +861,7 @@ export async function performDockerRollback(
801
861
  bootstrapSecret,
802
862
  cesServiceToken,
803
863
  extraAssistantEnv,
864
+ extraGatewayEnv,
804
865
  gatewayPort,
805
866
  imageTags: currentImageRefs,
806
867
  instanceName,