@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
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `vellum confirm <assistant> --request-id <id> --decision allow|deny`
3
+ *
4
+ * Resolve a pending tool confirmation on a running assistant via its
5
+ * runtime HTTP API. The assistant raises a `confirmation_request` event
6
+ * (with a `requestId`) when a tool exceeds the auto-approve risk
7
+ * threshold; this command answers it so the turn can proceed. Headless
8
+ * automation (e.g. the evals harness) uses it to approve requests that
9
+ * would otherwise hang waiting for an interactive user.
10
+ */
11
+
12
+ import { extractFlag } from "../lib/arg-utils.js";
13
+ import { AssistantClient } from "../lib/assistant-client.js";
14
+
15
+ function printUsage(): void {
16
+ console.log(`vellum confirm - Resolve a pending tool confirmation
17
+
18
+ USAGE:
19
+ vellum confirm [assistant] --request-id <id> [--decision allow|deny]
20
+
21
+ ARGUMENTS:
22
+ [assistant] Instance name (default: active assistant)
23
+
24
+ OPTIONS:
25
+ --request-id <id> The requestId from the confirmation_request event (required)
26
+ --decision <value> allow or deny (default: allow)
27
+ --json Output raw JSON response
28
+
29
+ EXAMPLES:
30
+ vellum confirm --request-id ede263d9-cc45-4d63-86f8-a656d17b3a3a
31
+ vellum confirm my-assistant --request-id req-1 --decision deny
32
+ vellum confirm --json --request-id req-1
33
+ `);
34
+ }
35
+
36
+ interface ParsedConfirmArgs {
37
+ assistantId?: string;
38
+ requestId: string;
39
+ decision: "allow" | "deny";
40
+ jsonOutput: boolean;
41
+ }
42
+
43
+ type ParseResult =
44
+ | { ok: true; value: ParsedConfirmArgs }
45
+ | { ok: false; error: string };
46
+
47
+ /**
48
+ * Parse `vellum confirm` arguments. Pure: does no I/O and never exits, so the
49
+ * positional/flag rules can be unit-tested. Defaults the decision to `allow`,
50
+ * which is the common automation case (approve and continue).
51
+ */
52
+ export function parseConfirmArgs(rawArgs: string[]): ParseResult {
53
+ const jsonOutput = rawArgs.includes("--json");
54
+ let args = rawArgs.filter((a) => a !== "--json");
55
+
56
+ const requestIdFlagPresent = args.includes("--request-id");
57
+ const [requestId, afterRequestId] = extractFlag(args, "--request-id");
58
+ args = afterRequestId;
59
+
60
+ const decisionFlagPresent = args.includes("--decision");
61
+ const [decisionRaw, afterDecision] = extractFlag(args, "--decision");
62
+ args = afterDecision;
63
+
64
+ // `extractFlag` strips a trailing value-less flag, which would otherwise let
65
+ // the next positional masquerade as the flag's value (or, for --decision,
66
+ // silently fall back to "allow" and approve a tool call the caller never
67
+ // meant to approve). Treat a flag supplied without a value as an error.
68
+ if (requestIdFlagPresent && requestId === undefined) {
69
+ return { ok: false, error: "--request-id requires a value." };
70
+ }
71
+ if (!requestId) {
72
+ return { ok: false, error: "--request-id is required." };
73
+ }
74
+
75
+ if (decisionFlagPresent && decisionRaw === undefined) {
76
+ return {
77
+ ok: false,
78
+ error: '--decision requires a value ("allow" or "deny").',
79
+ };
80
+ }
81
+ const decision = decisionRaw ?? "allow";
82
+ if (decision !== "allow" && decision !== "deny") {
83
+ return {
84
+ ok: false,
85
+ error: `--decision must be "allow" or "deny" (got "${decision}").`,
86
+ };
87
+ }
88
+
89
+ if (args.length >= 2) {
90
+ return { ok: false, error: "unexpected extra arguments." };
91
+ }
92
+
93
+ return {
94
+ ok: true,
95
+ value: { assistantId: args[0], requestId, decision, jsonOutput },
96
+ };
97
+ }
98
+
99
+ function exitWithUsage(error: string): never {
100
+ console.error(`Error: ${error}`);
101
+ console.error("");
102
+ printUsage();
103
+ process.exit(1);
104
+ }
105
+
106
+ export async function confirm(): Promise<void> {
107
+ const rawArgs = process.argv.slice(3);
108
+
109
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
110
+ printUsage();
111
+ return;
112
+ }
113
+
114
+ const parsed = parseConfirmArgs(rawArgs);
115
+ if (!parsed.ok) {
116
+ exitWithUsage(parsed.error);
117
+ }
118
+
119
+ const { assistantId, requestId, decision, jsonOutput } = parsed.value;
120
+
121
+ const client = new AssistantClient({ assistantId });
122
+
123
+ const response = await client.post("/confirm", { requestId, decision });
124
+
125
+ if (!response.ok) {
126
+ const body = await response.text().catch(() => "");
127
+ console.error(
128
+ `Error: HTTP ${response.status}: ${body || response.statusText}`,
129
+ );
130
+ process.exit(1);
131
+ }
132
+
133
+ const result = (await response.json()) as { accepted: boolean };
134
+
135
+ if (jsonOutput) {
136
+ console.log(JSON.stringify(result, null, 2));
137
+ } else {
138
+ console.log(
139
+ result.accepted
140
+ ? `Confirmation resolved (${decision})`
141
+ : `Confirmation not accepted`,
142
+ );
143
+ }
144
+ }
@@ -1,7 +1,7 @@
1
1
  import { connectImport } from "./connect/import.js";
2
2
 
3
3
  function printUsage(): void {
4
- console.log("Usage: vellum connect <subcommand>");
4
+ console.log("Usage: vellum connect [beta] <subcommand>");
5
5
  console.log("");
6
6
  console.log("Connect to an assistant paired from another machine.");
7
7
  console.log("");
@@ -28,6 +28,7 @@ import {
28
28
  canPromptForConfirmation,
29
29
  confirmAction,
30
30
  } from "../lib/confirm-action.js";
31
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
31
32
 
32
33
  interface DeviceRecord {
33
34
  hashedDeviceId: string;
@@ -38,7 +39,7 @@ interface DeviceRecord {
38
39
  }
39
40
 
40
41
  function printUsage(): void {
41
- console.log(`vellum devices - List and revoke devices paired to a local assistant
42
+ console.log(`vellum devices [beta] - List and revoke devices paired to a local assistant
42
43
 
43
44
  USAGE:
44
45
  vellum devices [name]
@@ -108,7 +109,7 @@ async function listDevices(entry: AssistantEntry, base: string): Promise<void> {
108
109
 
109
110
  let response: Response;
110
111
  try {
111
- response = await fetch(`${base}/v1/devices`, {
112
+ response = await loopbackSafeFetch(`${base}/v1/devices`, {
112
113
  method: "GET",
113
114
  headers: getClientRegistrationHeaders(CLI_INTERFACE_ID),
114
115
  });
@@ -186,7 +187,7 @@ async function revokeDevice(
186
187
 
187
188
  let response: Response;
188
189
  try {
189
- response = await fetch(`${base}/v1/devices/revoke`, {
190
+ response = await loopbackSafeFetch(`${base}/v1/devices/revoke`, {
190
191
  method: "POST",
191
192
  headers: {
192
193
  "Content-Type": "application/json",
@@ -181,6 +181,7 @@ interface HatchArgs {
181
181
  configValues: Record<string, string>;
182
182
  flagEnvVars: Record<string, string>;
183
183
  analyze: boolean;
184
+ disablePlatform: boolean;
184
185
  }
185
186
 
186
187
  function parseArgs(): HatchArgs {
@@ -188,6 +189,8 @@ function parseArgs(): HatchArgs {
188
189
  process.argv.slice(3),
189
190
  );
190
191
  const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
192
+ const disablePlatformAmbient = process.env.VELLUM_DISABLE_PLATFORM?.trim().toLowerCase();
193
+ let disablePlatform = disablePlatformAmbient === "true" || disablePlatformAmbient === "1";
191
194
  let species: Species = DEFAULT_SPECIES;
192
195
  let detached = false;
193
196
  let keepAlive = false;
@@ -233,6 +236,9 @@ function parseArgs(): HatchArgs {
233
236
  console.log(
234
237
  " --analyze Emit a structured hatch-timing log line on stdout",
235
238
  );
239
+ console.log(
240
+ " --disable-platform Suppress all outbound platform API calls",
241
+ );
236
242
  process.exit(0);
237
243
  } else if (arg === "-d") {
238
244
  detached = true;
@@ -293,11 +299,13 @@ function parseArgs(): HatchArgs {
293
299
  const value = next.slice(eqIndex + 1);
294
300
  configValues[key] = value;
295
301
  i++;
302
+ } else if (arg === "--disable-platform") {
303
+ disablePlatform = true;
296
304
  } else if (VALID_SPECIES.includes(arg as Species)) {
297
305
  species = arg as Species;
298
306
  } else {
299
307
  console.error(
300
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --watch, --source <path>, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>, --flag <key=value>, --analyze`,
308
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --watch, --source <path>, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>, --flag <key=value>, --analyze, --disable-platform`,
301
309
  );
302
310
  process.exit(1);
303
311
  }
@@ -314,6 +322,7 @@ function parseArgs(): HatchArgs {
314
322
  configValues,
315
323
  flagEnvVars,
316
324
  analyze,
325
+ disablePlatform,
317
326
  };
318
327
  }
319
328
 
@@ -549,8 +558,14 @@ export async function hatch(): Promise<void> {
549
558
  configValues,
550
559
  flagEnvVars,
551
560
  analyze,
561
+ disablePlatform,
552
562
  } = parseArgs();
553
563
 
564
+ if (disablePlatform) {
565
+ process.env.VELLUM_DISABLE_PLATFORM = "true";
566
+ flagEnvVars.VELLUM_DISABLE_PLATFORM = "true";
567
+ }
568
+
554
569
  if (watch && remote !== "local" && remote !== "docker") {
555
570
  console.error(
556
571
  "Error: --watch is only supported for local and docker hatch targets.",
@@ -26,6 +26,7 @@ import {
26
26
  } from "../lib/client-identity.js";
27
27
  import { GATEWAY_PORT } from "../lib/constants.js";
28
28
  import { getLocalLanIPv4 } from "../lib/local.js";
29
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
29
30
 
30
31
  function isLoopbackHost(url: string): boolean {
31
32
  try {
@@ -37,7 +38,7 @@ function isLoopbackHost(url: string): boolean {
37
38
  }
38
39
 
39
40
  function printUsage(): void {
40
- console.log(`vellum pair - Mint a device-scoped token for another machine
41
+ console.log(`vellum pair [beta] - Mint a device-scoped token for another machine
41
42
 
42
43
  USAGE:
43
44
  vellum pair [assistant] [options]
@@ -154,7 +155,7 @@ export async function pair(): Promise<void> {
154
155
 
155
156
  let response: Response;
156
157
  try {
157
- response = await fetch(`${mintUrl}/v1/pair`, {
158
+ response = await loopbackSafeFetch(`${mintUrl}/v1/pair`, {
158
159
  method: "POST",
159
160
  headers: {
160
161
  "Content-Type": "application/json",
@@ -16,6 +16,7 @@ import {
16
16
  platformPollJobStatus,
17
17
  } from "../lib/platform-client.js";
18
18
  import { performDockerRollback } from "../lib/upgrade-lifecycle.js";
19
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
19
20
 
20
21
  function printUsage(): void {
21
22
  console.log(
@@ -588,7 +589,7 @@ export async function restore(): Promise<void> {
588
589
 
589
590
  let response: Response;
590
591
  try {
591
- response = await fetch(
592
+ response = await loopbackSafeFetch(
592
593
  `${entry.runtimeUrl}/v1/migrations/import-preflight`,
593
594
  {
594
595
  method: "POST",
@@ -694,7 +695,7 @@ export async function restore(): Promise<void> {
694
695
 
695
696
  let response: Response;
696
697
  try {
697
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
698
+ response = await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/import`, {
698
699
  method: "POST",
699
700
  headers: {
700
701
  Authorization: `Bearer ${accessToken}`,
@@ -36,6 +36,7 @@ import {
36
36
  resetLogFile,
37
37
  writeToLogFile,
38
38
  } from "../lib/xdg-log.js";
39
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
39
40
 
40
41
  export { retireLocal };
41
42
 
@@ -96,7 +97,7 @@ async function retireVellum(
96
97
 
97
98
  const platformUrl = runtimeUrl || getPlatformUrl();
98
99
  const url = `${platformUrl}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
99
- const response = await fetch(url, {
100
+ const response = await loopbackSafeFetch(url, {
100
101
  method: "DELETE",
101
102
  headers: await authHeaders(token, runtimeUrl),
102
103
  });
@@ -1,4 +1,5 @@
1
1
  import { readPlatformToken, getWebUrl } from "../lib/platform-client.js";
2
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
2
3
 
3
4
  function printUsage(): void {
4
5
  console.log("Usage: vellum roadmap <subcommand>");
@@ -88,7 +89,7 @@ async function apiFetch(
88
89
  if (options.token) headers["X-Session-Token"] = options.token;
89
90
  if (options.body) headers["Content-Type"] = "application/json";
90
91
 
91
- return fetch(url, {
92
+ return loopbackSafeFetch(url, {
92
93
  method: options.method ?? "GET",
93
94
  headers,
94
95
  body: options.body ? JSON.stringify(options.body) : undefined,
@@ -1,5 +1,3 @@
1
- import { randomBytes } from "crypto";
2
-
3
1
  import {
4
2
  findAssistantByName,
5
3
  getActiveAssistant,
@@ -27,9 +25,8 @@ import {
27
25
  buildProgressEvent,
28
26
  buildStartingEvent,
29
27
  buildUpgradeCommitMessage,
30
- captureContainerEnv,
28
+ captureReplayState,
31
29
  commitWorkspaceViaGateway,
32
- CONTAINER_ENV_EXCLUDE_KEYS,
33
30
  fetchCurrentVersion,
34
31
  fetchPreviousVersion,
35
32
  performDockerRollback,
@@ -308,39 +305,13 @@ export async function rollback(): Promise<void> {
308
305
  `🔄 Rolling back Docker assistant '${instanceName}' to ${previousVersion}...\n`,
309
306
  );
310
307
 
311
- // Capture current container env
312
- console.log("💾 Capturing existing container environment...");
313
- const capturedEnv = await captureContainerEnv(res.assistantContainer);
314
- console.log(
315
- ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
316
- );
317
-
318
- // Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
319
- // set on gateway, not assistant) so it persists across container restarts.
320
- const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
321
- const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
322
-
323
- // Extract CES_SERVICE_TOKEN from captured env, or generate fresh one
324
- const cesServiceToken =
325
- capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
326
-
327
- // Extract or generate the shared JWT signing key.
328
- const signingKey =
329
- capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
330
-
331
- // Build extra env vars, excluding keys managed by buildServiceRunArgs
332
- const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
333
- for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
334
- if (process.env[envVar]) {
335
- envKeysSetByRunArgs.add(envVar);
336
- }
337
- }
338
- const extraAssistantEnv: Record<string, string> = {};
339
- for (const [key, value] of Object.entries(capturedEnv)) {
340
- if (!envKeysSetByRunArgs.has(key)) {
341
- extraAssistantEnv[key] = value;
342
- }
343
- }
308
+ const {
309
+ bootstrapSecret,
310
+ cesServiceToken,
311
+ signingKey,
312
+ extraAssistantEnv,
313
+ extraGatewayEnv,
314
+ } = await captureReplayState(res);
344
315
 
345
316
  // Parse gateway port from entry's runtimeUrl, fall back to default
346
317
  let gatewayPort = GATEWAY_INTERNAL_PORT;
@@ -401,6 +372,7 @@ export async function rollback(): Promise<void> {
401
372
  bootstrapSecret,
402
373
  cesServiceToken,
403
374
  extraAssistantEnv,
375
+ extraGatewayEnv,
404
376
  gatewayPort,
405
377
  imageTags: previousImageRefs,
406
378
  instanceName,
@@ -24,7 +24,7 @@ import {
24
24
  import { deleteGuardianToken } from "../lib/guardian-token";
25
25
 
26
26
  function printUsage(): void {
27
- console.log(`vellum unpair - Forget a paired assistant imported from another machine
27
+ console.log(`vellum unpair [beta] - Forget a paired assistant imported from another machine
28
28
 
29
29
  USAGE:
30
30
  vellum unpair <name> [--yes]
@@ -1,4 +1,3 @@
1
- import { randomBytes } from "crypto";
2
1
  import { spawnSync } from "child_process";
3
2
 
4
3
  import cliPkg from "../../package.json";
@@ -40,15 +39,15 @@ import {
40
39
  buildProgressEvent,
41
40
  buildStartingEvent,
42
41
  buildUpgradeCommitMessage,
43
- captureContainerEnv,
42
+ captureReplayState,
44
43
  captureUpgradeFailureLogs,
45
44
  commitWorkspaceViaGateway,
46
- CONTAINER_ENV_EXCLUDE_KEYS,
47
45
  rollbackMigrations,
48
46
  UPGRADE_PROGRESS,
49
47
  waitForReady,
50
48
  } from "../lib/upgrade-lifecycle.js";
51
49
  import { compareVersions } from "../lib/version-compat.js";
50
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
52
51
 
53
52
  interface UpgradeArgs {
54
53
  name: string | null;
@@ -232,7 +231,7 @@ async function upgradeDocker(
232
231
  lastWorkspaceMigrationId?: string;
233
232
  } = {};
234
233
  try {
235
- const healthResp = await fetch(
234
+ const healthResp = await loopbackSafeFetch(
236
235
  `${entry.runtimeUrl}/healthz?include=migrations`,
237
236
  {
238
237
  signal: AbortSignal.timeout(5000),
@@ -297,16 +296,13 @@ async function upgradeDocker(
297
296
  }),
298
297
  );
299
298
 
300
- console.log("💾 Capturing existing container environment...");
301
- const capturedEnv = await captureContainerEnv(res.assistantContainer);
302
- console.log(
303
- ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
304
- );
305
-
306
- // Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
307
- // set on gateway, not assistant) so it persists across container restarts.
308
- const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
309
- const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
299
+ const {
300
+ bootstrapSecret,
301
+ cesServiceToken,
302
+ signingKey,
303
+ extraAssistantEnv,
304
+ extraGatewayEnv,
305
+ } = await captureReplayState(res);
310
306
 
311
307
  // Notify connected clients that an upgrade is about to begin.
312
308
  // This must fire BEFORE any progress broadcasts so the UI sets
@@ -361,18 +357,6 @@ async function upgradeDocker(
361
357
  // use default
362
358
  }
363
359
 
364
- // Extract CES_SERVICE_TOKEN from the captured env so it can be passed via
365
- // the dedicated cesServiceToken parameter (which propagates it to all three
366
- // containers). If the old instance predates CES_SERVICE_TOKEN, generate a
367
- // fresh one so gateway and CES can authenticate.
368
- const cesServiceToken =
369
- capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
370
-
371
- // Extract or generate the shared JWT signing key. Pre-env-var instances
372
- // won't have it in capturedEnv, so generate fresh in that case.
373
- const signingKey =
374
- capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
375
-
376
360
  // Create pre-upgrade backup (best-effort, daemon must be running)
377
361
  await broadcastUpgradeEvent(
378
362
  entry.runtimeUrl,
@@ -415,23 +399,6 @@ async function upgradeDocker(
415
399
  await stopContainers(res);
416
400
  console.log("✅ Containers stopped\n");
417
401
 
418
- // Build the set of extra env vars to replay on the new assistant container.
419
- // Captured env vars serve as the base; keys already managed by
420
- // buildServiceRunArgs are excluded to avoid duplicates.
421
- const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
422
- // Only exclude keys that buildServiceRunArgs will actually set
423
- for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
424
- if (process.env[envVar]) {
425
- envKeysSetByRunArgs.add(envVar);
426
- }
427
- }
428
- const extraAssistantEnv: Record<string, string> = {};
429
- for (const [key, value] of Object.entries(capturedEnv)) {
430
- if (!envKeysSetByRunArgs.has(key)) {
431
- extraAssistantEnv[key] = value;
432
- }
433
- }
434
-
435
402
  console.log("🚀 Starting upgraded containers...");
436
403
  await startContainers(
437
404
  {
@@ -439,6 +406,7 @@ async function upgradeDocker(
439
406
  bootstrapSecret,
440
407
  cesServiceToken,
441
408
  extraAssistantEnv,
409
+ extraGatewayEnv,
442
410
  gatewayPort,
443
411
  imageTags,
444
412
  instanceName,
@@ -544,6 +512,7 @@ async function upgradeDocker(
544
512
  bootstrapSecret,
545
513
  cesServiceToken,
546
514
  extraAssistantEnv,
515
+ extraGatewayEnv,
547
516
  gatewayPort,
548
517
  imageTags: previousImageRefs,
549
518
  instanceName,
@@ -727,7 +696,7 @@ async function upgradePlatform(
727
696
  body.version = version;
728
697
  }
729
698
 
730
- const response = await fetch(url, {
699
+ const response = await loopbackSafeFetch(url, {
731
700
  method: "POST",
732
701
  headers,
733
702
  body: JSON.stringify(body),
@@ -7,7 +7,12 @@ import {
7
7
  saveAssistantEntry,
8
8
  } from "../lib/assistant-config.js";
9
9
  import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
10
- import { seedGuardianTokenFromSiblingEnv } from "../lib/guardian-token.js";
10
+ import {
11
+ leaseGuardianToken,
12
+ loadGuardianToken,
13
+ resetGuardianBootstrap,
14
+ seedGuardianTokenFromSiblingEnv,
15
+ } from "../lib/guardian-token.js";
11
16
  import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
12
17
  import {
13
18
  generateLocalSigningKey,
@@ -37,11 +42,23 @@ export async function wake(): Promise<void> {
37
42
  console.log(
38
43
  " --foreground Run assistant in foreground with logs printed to terminal",
39
44
  );
45
+ console.log(
46
+ " --repair-guardian Re-provision the guardian token if missing (resets the\n" +
47
+ " gateway bootstrap and re-leases — REVOKES other device-bound\n" +
48
+ " tokens, so only use deliberately, never from auto-repair)",
49
+ );
40
50
  process.exit(0);
41
51
  }
42
52
 
43
53
  const watch = args.includes("--watch");
44
54
  const foreground = args.includes("--foreground");
55
+ // Re-leasing the guardian token calls guardian/init, which revokes every
56
+ // other device-bound token (other tabs, other local clients on this machine).
57
+ // Gate it behind an explicit flag so the automatic connect-repair path
58
+ // (`runWake` spawns `wake <id>` with no flags) can never revoke a live session
59
+ // — it only ever restarts + sibling-seeds. A genuine spent-bootstrap brick is
60
+ // recovered deliberately via `vellum wake <id> --repair-guardian`.
61
+ const repairGuardian = args.includes("--repair-guardian");
45
62
  const nameArg = args.find((a) => !a.startsWith("-"));
46
63
  const entry = resolveTargetAssistant(nameArg);
47
64
 
@@ -221,6 +238,37 @@ export async function wake(): Promise<void> {
221
238
  console.log(" Seeded guardian token from sibling environment.");
222
239
  }
223
240
 
241
+ // Last-resort recovery (explicit `--repair-guardian` only): if no guardian
242
+ // token exists for this env even after sibling seeding, re-provision one. The
243
+ // single-use bootstrap secret may already be spent — a prior connect can
244
+ // lease a token that's then lost, or the gateway marks the secret consumed
245
+ // before the client persists it — which otherwise bricks connect into a
246
+ // 401 → auth-rate-limit → 429 cascade with no path back short of retire+hatch.
247
+ // Reset the gateway's bootstrap lock+consumed state (loopback-only, authorized
248
+ // by the lockfile secret — mirrors the macOS client's forceReBootstrap), then
249
+ // re-lease. Gated behind the flag because the re-lease revokes other
250
+ // device-bound tokens; it must never run from the automatic repair path.
251
+ if (repairGuardian && !loadGuardianToken(entry.assistantId)) {
252
+ const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
253
+ const maxAttempts = 3;
254
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
255
+ try {
256
+ await resetGuardianBootstrap(loopbackUrl, bootstrapSecret);
257
+ await leaseGuardianToken(loopbackUrl, entry.assistantId, bootstrapSecret);
258
+ console.log(" Re-provisioned guardian token.");
259
+ break;
260
+ } catch (err) {
261
+ if (attempt < maxAttempts) {
262
+ await new Promise((r) => setTimeout(r, 2000 * 2 ** (attempt - 1)));
263
+ } else {
264
+ console.warn(
265
+ ` Guardian token re-provision failed after ${maxAttempts} attempts: ${err}`,
266
+ );
267
+ }
268
+ }
269
+ }
270
+ }
271
+
224
272
  // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
225
273
  const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
226
274
  const ngrokChild = await maybeStartNgrokTunnel(
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import cliPkg from "../package.json";
4
4
  import { backup } from "./commands/backup";
5
5
  import { clean } from "./commands/clean";
6
6
  import { client } from "./commands/client";
7
+ import { confirm } from "./commands/confirm";
7
8
  import { connect } from "./commands/connect";
8
9
  import { devices } from "./commands/devices";
9
10
  import { env } from "./commands/env";
@@ -40,6 +41,7 @@ const commands = {
40
41
  backup,
41
42
  clean,
42
43
  client,
44
+ confirm,
43
45
  connect,
44
46
  devices,
45
47
  env,
@@ -81,8 +83,13 @@ function printHelp(): void {
81
83
  console.log(" backup Export a backup of a running assistant");
82
84
  console.log(" clean Kill orphaned vellum processes");
83
85
  console.log(" client Connect to a hatched assistant");
84
- console.log(" connect Import an assistant paired from another machine");
85
- console.log(" devices List or revoke devices paired to a local assistant");
86
+ console.log(" confirm Resolve a pending tool confirmation on an assistant");
87
+ console.log(
88
+ " connect Import an assistant paired from another machine [beta]",
89
+ );
90
+ console.log(
91
+ " devices List or revoke devices paired to a local assistant [beta]",
92
+ );
86
93
  console.log(" env Manage the default CLI environment");
87
94
  console.log(" events Stream events from a running assistant");
88
95
  console.log(" exec Execute a command inside an assistant's container");
@@ -94,7 +101,7 @@ function printHelp(): void {
94
101
  console.log(" logout Log out of the Vellum platform");
95
102
  console.log(" message Send a message to a running assistant");
96
103
  console.log(
97
- " pair Mint a device-scoped token to connect another machine",
104
+ " pair Mint a device-scoped token to connect another machine [beta]",
98
105
  );
99
106
  console.log(
100
107
  " ps List assistants (or processes for a specific assistant)",
@@ -113,7 +120,7 @@ function printHelp(): void {
113
120
  console.log(" terminal Open a terminal into a managed assistant container");
114
121
  console.log(" tunnel Create a tunnel for a locally hosted assistant");
115
122
  console.log(
116
- " unpair Forget a paired assistant imported from another machine",
123
+ " unpair Forget a paired assistant imported from another machine [beta]",
117
124
  );
118
125
  console.log(" upgrade Upgrade an assistant to a newer version");
119
126
  console.log(" use Set the active assistant for commands");