@vellumai/cli 0.8.5 → 0.8.7-dev.202606052118.34cd356

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 (102) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +22 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  16. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  18. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  19. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  20. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  21. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  22. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  26. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  27. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  28. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  29. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  30. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  31. package/package.json +12 -1
  32. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  33. package/src/__tests__/backup.test.ts +38 -0
  34. package/src/__tests__/clean.test.ts +179 -0
  35. package/src/__tests__/client-token.test.ts +87 -0
  36. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  37. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  38. package/src/__tests__/connect-import.test.ts +317 -0
  39. package/src/__tests__/devices.test.ts +272 -0
  40. package/src/__tests__/env-drift.test.ts +32 -44
  41. package/src/__tests__/flags.test.ts +248 -0
  42. package/src/__tests__/guardian-token.test.ts +126 -2
  43. package/src/__tests__/multi-local.test.ts +1 -1
  44. package/src/__tests__/orphan-detection.test.ts +8 -6
  45. package/src/__tests__/pair.test.ts +271 -0
  46. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  47. package/src/__tests__/recover.test.ts +307 -0
  48. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  49. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  50. package/src/__tests__/unpair.test.ts +163 -0
  51. package/src/__tests__/wake.test.ts +215 -0
  52. package/src/commands/backup.ts +2 -0
  53. package/src/commands/client.ts +569 -39
  54. package/src/commands/connect/import.ts +217 -0
  55. package/src/commands/connect.ts +31 -0
  56. package/src/commands/devices.ts +247 -0
  57. package/src/commands/env.ts +1 -1
  58. package/src/commands/flags.ts +269 -0
  59. package/src/commands/gateway/token.ts +73 -0
  60. package/src/commands/gateway.ts +29 -0
  61. package/src/commands/logs.ts +6 -18
  62. package/src/commands/pair.ts +222 -0
  63. package/src/commands/ps.ts +57 -41
  64. package/src/commands/recover.ts +47 -9
  65. package/src/commands/restore.ts +8 -1
  66. package/src/commands/retire.ts +23 -70
  67. package/src/commands/rollback.ts +2 -14
  68. package/src/commands/sleep.ts +7 -0
  69. package/src/commands/ssh.ts +5 -24
  70. package/src/commands/teleport.ts +34 -26
  71. package/src/commands/tunnel.ts +46 -2
  72. package/src/commands/unpair.ts +118 -0
  73. package/src/commands/upgrade.ts +8 -16
  74. package/src/commands/wake.ts +75 -45
  75. package/src/components/DefaultMainScreen.tsx +100 -14
  76. package/src/index.ts +22 -0
  77. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  78. package/src/lib/__tests__/step-runner.test.ts +49 -1
  79. package/src/lib/assistant-client.ts +58 -37
  80. package/src/lib/assistant-config.ts +28 -3
  81. package/src/lib/cloudflare-tunnel.ts +276 -0
  82. package/src/lib/config-utils.ts +24 -3
  83. package/src/lib/confirm-action.ts +57 -0
  84. package/src/lib/docker.ts +82 -8
  85. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  86. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  87. package/src/lib/environments/paths.ts +1 -1
  88. package/src/lib/environments/resolve.ts +11 -35
  89. package/src/lib/guardian-token.ts +132 -9
  90. package/src/lib/hatch-local.ts +75 -33
  91. package/src/lib/http-client.ts +1 -3
  92. package/src/lib/lifecycle-reporter.ts +31 -0
  93. package/src/lib/local.ts +193 -298
  94. package/src/lib/orphan-detection.ts +9 -5
  95. package/src/lib/pgrep.ts +5 -1
  96. package/src/lib/platform-client.ts +97 -49
  97. package/src/lib/process.ts +109 -39
  98. package/src/lib/retire-local.ts +28 -14
  99. package/src/lib/segments-to-plain-text.ts +35 -0
  100. package/src/lib/step-runner.ts +67 -7
  101. package/src/lib/sync-cloud-assistants.ts +17 -0
  102. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -1,6 +1,7 @@
1
1
  import { join } from "path";
2
2
 
3
3
  import {
4
+ extractHostFromUrl,
4
5
  findAssistantByName,
5
6
  formatAssistantLookupError,
6
7
  formatAssistantReference,
@@ -9,6 +10,7 @@ import {
9
10
  getDaemonPidPath,
10
11
  loadAllAssistants,
11
12
  lookupAssistantByIdentifier,
13
+ resolveCloud,
12
14
  type AssistantEntry,
13
15
  } from "../lib/assistant-config";
14
16
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
@@ -26,7 +28,7 @@ import { existsSync } from "fs";
26
28
  import {
27
29
  classifyProcess,
28
30
  detectOrphanedProcesses,
29
- isProcessAlive,
31
+ isPidAlive,
30
32
  parseRemotePs,
31
33
  readPidFile,
32
34
  } from "../lib/orphan-detection";
@@ -149,35 +151,25 @@ const REMOTE_PS_CMD = [
149
151
  "| grep -v grep",
150
152
  ].join(" ");
151
153
 
152
- function extractHostFromUrl(url: string): string {
153
- try {
154
- const parsed = new URL(url);
155
- return parsed.hostname;
156
- } catch {
157
- return url.replace(/^https?:\/\//, "").split(":")[0];
158
- }
159
- }
160
-
161
- function resolveCloud(entry: AssistantEntry): string {
162
- if (entry.cloud) return entry.cloud;
163
- if (entry.project) return "gcp";
164
- if (entry.sshUser) return "custom";
165
- return "local";
166
- }
154
+ const REMOTE_SSH_TIMEOUT_MS = 30_000;
167
155
 
168
156
  async function getRemoteProcessesGcp(entry: AssistantEntry): Promise<string> {
169
- return execOutput("gcloud", [
170
- "compute",
171
- "ssh",
172
- `${entry.sshUser ?? entry.assistantId}@${entry.assistantId}`,
173
- `--zone=${entry.zone}`,
174
- `--project=${entry.project}`,
175
- `--command=${REMOTE_PS_CMD}`,
176
- "--ssh-flag=-o StrictHostKeyChecking=no",
177
- "--ssh-flag=-o UserKnownHostsFile=/dev/null",
178
- "--ssh-flag=-o ConnectTimeout=10",
179
- "--ssh-flag=-o LogLevel=ERROR",
180
- ]);
157
+ return execOutput(
158
+ "gcloud",
159
+ [
160
+ "compute",
161
+ "ssh",
162
+ `${entry.sshUser ?? entry.assistantId}@${entry.assistantId}`,
163
+ `--zone=${entry.zone}`,
164
+ `--project=${entry.project}`,
165
+ `--command=${REMOTE_PS_CMD}`,
166
+ "--ssh-flag=-o StrictHostKeyChecking=no",
167
+ "--ssh-flag=-o UserKnownHostsFile=/dev/null",
168
+ "--ssh-flag=-o ConnectTimeout=10",
169
+ "--ssh-flag=-o LogLevel=ERROR",
170
+ ],
171
+ { timeoutMs: REMOTE_SSH_TIMEOUT_MS },
172
+ );
181
173
  }
182
174
 
183
175
  async function getRemoteProcessesCustom(
@@ -185,7 +177,9 @@ async function getRemoteProcessesCustom(
185
177
  ): Promise<string> {
186
178
  const host = extractHostFromUrl(entry.runtimeUrl);
187
179
  const sshUser = entry.sshUser ?? "root";
188
- return execOutput("ssh", [...SSH_OPTS, `${sshUser}@${host}`, REMOTE_PS_CMD]);
180
+ return execOutput("ssh", [...SSH_OPTS, `${sshUser}@${host}`, REMOTE_PS_CMD], {
181
+ timeoutMs: REMOTE_SSH_TIMEOUT_MS,
182
+ });
189
183
  }
190
184
 
191
185
  interface ProcessSpec {
@@ -203,9 +197,13 @@ interface DetectedProcess {
203
197
  watch: boolean;
204
198
  }
205
199
 
200
+ const LOCAL_CMD_TIMEOUT_MS = 5_000;
201
+
206
202
  async function isWatchMode(pid: string): Promise<boolean> {
207
203
  try {
208
- const args = await execOutput("ps", ["-p", pid, "-o", "args="]);
204
+ const args = await execOutput("ps", ["-p", pid, "-o", "args="], {
205
+ timeoutMs: LOCAL_CMD_TIMEOUT_MS,
206
+ });
209
207
  return args.includes("--watch");
210
208
  } catch {
211
209
  return false;
@@ -242,7 +240,7 @@ async function detectProcess(spec: ProcessSpec): Promise<DetectedProcess> {
242
240
 
243
241
  // Tier 3: PID file fallback
244
242
  const filePid = readPidFile(spec.pidFile);
245
- if (filePid && isProcessAlive(filePid)) {
243
+ if (filePid && isPidAlive(filePid)) {
246
244
  const watch = await isWatchMode(filePid);
247
245
  return {
248
246
  name: spec.name,
@@ -320,12 +318,11 @@ async function getDockerContainerState(
320
318
  containerName: string,
321
319
  ): Promise<string | null> {
322
320
  try {
323
- const output = await execOutput("docker", [
324
- "inspect",
325
- "--format",
326
- "{{.State.Status}}",
327
- containerName,
328
- ]);
321
+ const output = await execOutput(
322
+ "docker",
323
+ ["inspect", "--format", "{{.State.Status}}", containerName],
324
+ { timeoutMs: LOCAL_CMD_TIMEOUT_MS },
325
+ );
329
326
  return output.trim() || "unknown";
330
327
  } catch {
331
328
  return null;
@@ -447,6 +444,22 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
447
444
  return;
448
445
  }
449
446
 
447
+ if (cloud === "paired") {
448
+ // A remote assistant paired from another machine: no local process to
449
+ // list — probe the remote gateway's health over the bearer token instead.
450
+ const token = loadGuardianToken(entry.assistantId)?.accessToken;
451
+ const health = await checkHealth(entry.runtimeUrl, token);
452
+ const rows: TableRow[] = [
453
+ {
454
+ name: "gateway",
455
+ status: withStatusEmoji(health.status),
456
+ info: entry.runtimeUrl + (health.detail ? ` | ${health.detail}` : ""),
457
+ },
458
+ ];
459
+ printTable(rows);
460
+ return;
461
+ }
462
+
450
463
  let output: string;
451
464
  try {
452
465
  if (cloud === "gcp") {
@@ -458,9 +471,12 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
458
471
  process.exit(1);
459
472
  }
460
473
  } catch (error) {
461
- console.error(
462
- `Failed to list processes: ${error instanceof Error ? error.message : error}`,
463
- );
474
+ const msg = error instanceof Error ? error.message : String(error);
475
+ if (msg.includes("timed out")) {
476
+ console.warn(`Warning: remote process listing timed out — ${msg}`);
477
+ return;
478
+ }
479
+ console.error(`Failed to list processes: ${msg}`);
464
480
  process.exit(1);
465
481
  }
466
482
 
@@ -496,7 +512,7 @@ async function getAssistantListHealth(
496
512
  // TODO(ATL-306): Remove readPidFile/getDaemonPidPath in favor of
497
513
  // fetching daemon PIDs via the health API (Gateway Security Migration).
498
514
  const pid = readPidFile(getDaemonPidPath(resources));
499
- const alive = pid !== null && isProcessAlive(pid);
515
+ const alive = pid !== null && isPidAlive(pid);
500
516
  if (!alive) {
501
517
  return { status: "sleeping", detail: null };
502
518
  }
@@ -1,6 +1,12 @@
1
- import { existsSync, readFileSync, unlinkSync } from "fs";
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ } from "fs";
2
8
  import { homedir } from "os";
3
- import { join } from "path";
9
+ import { basename, dirname, join } from "path";
4
10
 
5
11
  import { saveAssistantEntry } from "../lib/assistant-config";
6
12
  import type { AssistantEntry } from "../lib/assistant-config";
@@ -21,8 +27,22 @@ export async function recover(): Promise<void> {
21
27
  "Restore a previously retired local assistant from its archive.",
22
28
  );
23
29
  console.log("");
30
+ console.log(
31
+ "Extracts the archived workspace data back to its original location,",
32
+ );
33
+ console.log(
34
+ "restores the lockfile entry, and starts the assistant and gateway.",
35
+ );
36
+ console.log(
37
+ "Archives are stored in $XDG_DATA_HOME/vellum/retired/ (default: ~/.local/share/vellum/retired/).",
38
+ );
39
+ console.log("");
24
40
  console.log("Arguments:");
25
41
  console.log(" <name> Name of the retired assistant to recover");
42
+ console.log("");
43
+ console.log("Examples:");
44
+ console.log(" $ vellum recover my-assistant");
45
+ console.log(" $ vellum recover aria-7f3a");
26
46
  process.exit(0);
27
47
  }
28
48
 
@@ -61,11 +81,27 @@ export async function recover(): Promise<void> {
61
81
  process.exit(1);
62
82
  }
63
83
 
64
- // 4. Extract archive
65
- // TODO: extraction target is hardcoded to homedir(); multi-instance entries
66
- // whose instanceDir differs from homedir will extract to the wrong
67
- // location. Tracked separately from the collision-check regression.
68
- await exec("tar", ["xzf", archivePath, "-C", homedir()]);
84
+ // 4. Determine the original target directory, then extract and rename.
85
+ //
86
+ // retireLocal archives either the full instanceDir (named instances) or just
87
+ // the .vellum/ subdirectory (default instance whose instanceDir === homedir()).
88
+ // The directory is staged under `<archive>.staging` inside the retired dir
89
+ // before being packed with `tar -C <retiredDir> <stagingBasename>`, so the
90
+ // top-level entry inside the tarball is always `<name>.tar.gz.staging`.
91
+ //
92
+ // Correct restoration: extract to retiredDir, then rename the staging entry
93
+ // back to the original target path. Using homedir() as the -C target was
94
+ // wrong for any instance stored outside the home directory.
95
+ const isNamedInstance = entry.resources.instanceDir !== homedir();
96
+ const targetDir = isNamedInstance
97
+ ? entry.resources.instanceDir
98
+ : join(entry.resources.instanceDir, ".vellum");
99
+ const retiredDir = dirname(archivePath);
100
+ const extractedPath = join(retiredDir, basename(archivePath) + ".staging");
101
+
102
+ await exec("tar", ["xzf", archivePath, "-C", retiredDir]);
103
+ mkdirSync(dirname(targetDir), { recursive: true });
104
+ renameSync(extractedPath, targetDir);
69
105
 
70
106
  // 5. Restore lockfile entry
71
107
  saveAssistantEntry(entry);
@@ -74,14 +110,16 @@ export async function recover(): Promise<void> {
74
110
  unlinkSync(archivePath);
75
111
  unlinkSync(metadataPath);
76
112
 
77
- // 7. Persist signing key so it survives daemon/gateway restarts (same as wake)
113
+ // 7. Persist signing key and bootstrap secret so they survive daemon/gateway restarts
78
114
  const signingKey = generateLocalSigningKey();
115
+ const bootstrapSecret = generateLocalSigningKey();
79
116
  entry.resources = { ...entry.resources, signingKey };
117
+ entry.guardianBootstrapSecret = bootstrapSecret;
80
118
  saveAssistantEntry(entry);
81
119
 
82
120
  // 8. Start daemon + gateway
83
121
  await startLocalDaemon(false, entry.resources, { signingKey });
84
- await startGateway(false, entry.resources, { signingKey });
122
+ await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
85
123
 
86
124
  console.log(`✅ Recovered assistant '${name}'.`);
87
125
  }
@@ -97,6 +97,7 @@ async function getAccessToken(
97
97
  runtimeUrl: string,
98
98
  assistantId: string,
99
99
  displayName: string,
100
+ bootstrapSecret?: string,
100
101
  ): Promise<string> {
101
102
  const tokenData = loadGuardianToken(assistantId);
102
103
 
@@ -105,7 +106,11 @@ async function getAccessToken(
105
106
  }
106
107
 
107
108
  try {
108
- const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
109
+ const freshToken = await leaseGuardianToken(
110
+ runtimeUrl,
111
+ assistantId,
112
+ bootstrapSecret,
113
+ );
109
114
  return freshToken.accessToken;
110
115
  } catch (err) {
111
116
  const msg = err instanceof Error ? err.message : String(err);
@@ -574,6 +579,7 @@ export async function restore(): Promise<void> {
574
579
  entry.runtimeUrl,
575
580
  entry.assistantId,
576
581
  name,
582
+ entry.guardianBootstrapSecret,
577
583
  );
578
584
 
579
585
  if (dryRun) {
@@ -679,6 +685,7 @@ export async function restore(): Promise<void> {
679
685
  entry.runtimeUrl,
680
686
  entry.assistantId,
681
687
  name,
688
+ entry.guardianBootstrapSecret,
682
689
  );
683
690
  }
684
691
 
@@ -2,15 +2,21 @@ import { existsSync, unlinkSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
4
  import {
5
+ extractHostFromUrl,
5
6
  formatAssistantLookupError,
6
7
  formatAssistantReference,
7
8
  getAssistantDisplayName,
8
9
  loadAllAssistants,
9
10
  lookupAssistantByIdentifier,
10
11
  removeAssistantEntry,
12
+ resolveCloud,
13
+ type AssistantEntry,
11
14
  } from "../lib/assistant-config.js";
12
- import type { AssistantEntry } from "../lib/assistant-config.js";
13
15
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
16
+ import {
17
+ canPromptForConfirmation,
18
+ confirmAction,
19
+ } from "../lib/confirm-action.js";
14
20
  import { getConfigDir } from "../lib/environments/paths.js";
15
21
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
16
22
  import {
@@ -31,28 +37,6 @@ import {
31
37
  writeToLogFile,
32
38
  } from "../lib/xdg-log.js";
33
39
 
34
- function resolveCloud(entry: AssistantEntry): string {
35
- if (entry.cloud) {
36
- return entry.cloud;
37
- }
38
- if (entry.project) {
39
- return "gcp";
40
- }
41
- if (entry.sshUser) {
42
- return "custom";
43
- }
44
- return "local";
45
- }
46
-
47
- function extractHostFromUrl(url: string): string {
48
- try {
49
- const parsed = new URL(url);
50
- return parsed.hostname;
51
- } catch {
52
- return url.replace(/^https?:\/\//, "").split(":")[0];
53
- }
54
- }
55
-
56
40
  export { retireLocal };
57
41
 
58
42
  interface RetireArgs {
@@ -172,51 +156,6 @@ function printRetireTarget(entry: AssistantEntry, cloud: string): void {
172
156
  console.log("");
173
157
  }
174
158
 
175
- function canPromptForRetireConfirmation(): boolean {
176
- return (
177
- process.stdin.isTTY === true &&
178
- process.stdout.isTTY === true &&
179
- typeof process.stdin.setRawMode === "function"
180
- );
181
- }
182
-
183
- async function confirmRetireInteractive(): Promise<boolean> {
184
- const stdin = process.stdin;
185
- const stdout = process.stdout;
186
- const wasRaw = stdin.isRaw === true;
187
- const wasPaused = stdin.isPaused();
188
-
189
- stdout.write("Press Enter to retire, or Esc/q to cancel: ");
190
- stdin.setRawMode(true);
191
- stdin.resume();
192
-
193
- return await new Promise<boolean>((resolve) => {
194
- const cleanup = () => {
195
- stdin.off("data", onData);
196
- stdin.setRawMode(wasRaw);
197
- if (wasPaused) {
198
- stdin.pause();
199
- }
200
- stdout.write("\n");
201
- };
202
-
203
- const onData = (chunk: Buffer) => {
204
- const byte = chunk[0];
205
- if (byte === 13 || byte === 10) {
206
- cleanup();
207
- resolve(true);
208
- return;
209
- }
210
- if (byte === 27 || byte === 3 || byte === 113 || byte === 81) {
211
- cleanup();
212
- resolve(false);
213
- }
214
- };
215
-
216
- stdin.on("data", onData);
217
- });
218
- }
219
-
220
159
  /** Patch console methods to also append output to the given log file descriptor. */
221
160
  function teeConsoleToLogFile(fd: number | "ignore"): void {
222
161
  if (fd === "ignore") return;
@@ -310,10 +249,22 @@ async function retireInner(): Promise<void> {
310
249
  const assistantId = entry.assistantId;
311
250
  const source = parsed.source;
312
251
  const cloud = resolveCloud(entry);
252
+
253
+ if (cloud === "paired") {
254
+ // A remote assistant paired from another machine. Retiring tears the
255
+ // assistant down — that can only happen on its host machine, never from a
256
+ // paired machine, which holds nothing but a pairing record. (Removing that
257
+ // local record is `vellum unpair`'s job, not retire's.)
258
+ console.error(
259
+ `Error: '${assistantId}' is a remote assistant paired from another machine — it can't be retired from here. Retiring tears down the assistant, which can only be done on its host machine. To remove the local pairing record on this machine, run \`vellum unpair ${assistantId}\`.`,
260
+ );
261
+ process.exit(1);
262
+ }
263
+
313
264
  printRetireTarget(entry, cloud);
314
265
 
315
266
  if (!parsed.yes) {
316
- if (!canPromptForRetireConfirmation()) {
267
+ if (!canPromptForConfirmation()) {
317
268
  console.error(
318
269
  "Error: Refusing to retire without confirmation in a non-interactive terminal.",
319
270
  );
@@ -321,7 +272,9 @@ async function retireInner(): Promise<void> {
321
272
  process.exit(1);
322
273
  }
323
274
 
324
- const confirmed = await confirmRetireInteractive();
275
+ const confirmed = await confirmAction(
276
+ "Press Enter to retire, or Esc/q to cancel: ",
277
+ );
325
278
  if (!confirmed) {
326
279
  console.log("Retire cancelled.");
327
280
  process.exit(1);
@@ -4,9 +4,10 @@ import {
4
4
  findAssistantByName,
5
5
  getActiveAssistant,
6
6
  loadAllAssistants,
7
+ resolveCloud,
7
8
  saveAssistantEntry,
9
+ type AssistantEntry,
8
10
  } from "../lib/assistant-config";
9
- import type { AssistantEntry } from "../lib/assistant-config";
10
11
  import {
11
12
  captureImageRefs,
12
13
  GATEWAY_INTERNAL_PORT,
@@ -90,19 +91,6 @@ function parseArgs(): { name: string | null; version: string | null } {
90
91
  return { name, version };
91
92
  }
92
93
 
93
- function resolveCloud(entry: AssistantEntry): string {
94
- if (entry.cloud) {
95
- return entry.cloud;
96
- }
97
- if (entry.project) {
98
- return "gcp";
99
- }
100
- if (entry.sshUser) {
101
- return "custom";
102
- }
103
- return "local";
104
- }
105
-
106
94
  /**
107
95
  * Resolve which assistant to target for the rollback command. Priority:
108
96
  * 1. Explicit name argument
@@ -82,6 +82,13 @@ export async function sleep(): Promise<void> {
82
82
  process.exit(1);
83
83
  }
84
84
 
85
+ if (entry.cloud === "paired") {
86
+ console.error(
87
+ `Error: '${entry.assistantId}' is a remote assistant paired from another machine — its lifecycle is managed on its host machine, not here. Use \`vellum client ${entry.assistantId}\` to chat with it.`,
88
+ );
89
+ process.exit(1);
90
+ }
91
+
85
92
  if (entry.cloud && entry.cloud !== "local") {
86
93
  console.error(
87
94
  `Error: 'vellum sleep' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
@@ -1,7 +1,10 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
- import { resolveAssistant } from "../lib/assistant-config";
4
- import type { AssistantEntry } from "../lib/assistant-config";
3
+ import {
4
+ extractHostFromUrl,
5
+ resolveAssistant,
6
+ resolveCloud,
7
+ } from "../lib/assistant-config";
5
8
  import { dockerResourceNames } from "../lib/docker";
6
9
  import { getPlatformUrl, readPlatformToken } from "../lib/platform-client";
7
10
  import { sshAppleContainer } from "../lib/ssh-apple-container";
@@ -18,28 +21,6 @@ const SSH_OPTS = [
18
21
  "LogLevel=ERROR",
19
22
  ];
20
23
 
21
- function resolveCloud(entry: AssistantEntry): string {
22
- if (entry.cloud) {
23
- return entry.cloud;
24
- }
25
- if (entry.project) {
26
- return "gcp";
27
- }
28
- if (entry.sshUser) {
29
- return "custom";
30
- }
31
- return "local";
32
- }
33
-
34
- function extractHostFromUrl(url: string): string {
35
- try {
36
- const parsed = new URL(url);
37
- return parsed.hostname;
38
- } catch {
39
- return url.replace(/^https?:\/\//, "").split(":")[0];
40
- }
41
- }
42
-
43
24
  export async function ssh(): Promise<void> {
44
25
  const args = process.argv.slice(3);
45
26
  if (args.includes("--help") || args.includes("-h")) {
@@ -3,10 +3,11 @@ import {
3
3
  loadAllAssistants,
4
4
  getDaemonPidPath,
5
5
  removeAssistantEntry,
6
+ resolveCloud,
6
7
  saveAssistantEntry,
7
8
  setActiveAssistant,
9
+ type AssistantEntry,
8
10
  } from "../lib/assistant-config.js";
9
- import type { AssistantEntry } from "../lib/assistant-config.js";
10
11
  import {
11
12
  loadGuardianToken,
12
13
  leaseGuardianToken,
@@ -214,12 +215,6 @@ export function parseArgs(argv: string[]): {
214
215
  return { from, to, targetEnv, targetName, keepSource, dryRun, help };
215
216
  }
216
217
 
217
- function resolveCloud(entry: AssistantEntry): string {
218
- return (
219
- entry.cloud || (entry.project ? "gcp" : entry.sshUser ? "custom" : "local")
220
- );
221
- }
222
-
223
218
  // ---------------------------------------------------------------------------
224
219
  // Auth helper — same pattern as restore.ts
225
220
  // ---------------------------------------------------------------------------
@@ -228,7 +223,7 @@ async function getAccessToken(
228
223
  runtimeUrl: string,
229
224
  assistantId: string,
230
225
  displayName: string,
231
- options?: { forceRefresh?: boolean },
226
+ options?: { forceRefresh?: boolean; bootstrapSecret?: string },
232
227
  ): Promise<string> {
233
228
  // When forceRefresh is set (e.g. after a runtime 401 on the cached token)
234
229
  // we skip the cache and lease a brand-new token from the gateway, so a
@@ -242,7 +237,11 @@ async function getAccessToken(
242
237
  }
243
238
 
244
239
  try {
245
- const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
240
+ const freshToken = await leaseGuardianToken(
241
+ runtimeUrl,
242
+ assistantId,
243
+ options?.bootstrapSecret,
244
+ );
246
245
  return freshToken.accessToken;
247
246
  } catch (err) {
248
247
  const msg = err instanceof Error ? err.message : String(err);
@@ -281,11 +280,15 @@ function isRuntime401(err: unknown): boolean {
281
280
  * — propagates to the caller.
282
281
  */
283
282
  async function callRuntimeWithAuthRetry<T>(
284
- runtimeUrl: string,
285
- assistantId: string,
283
+ entry: AssistantEntry,
286
284
  fn: (token: string) => Promise<T>,
287
285
  ): Promise<T> {
288
- const firstToken = await getAccessToken(runtimeUrl, assistantId, assistantId);
286
+ const firstToken = await getAccessToken(
287
+ entry.runtimeUrl,
288
+ entry.assistantId,
289
+ entry.assistantId,
290
+ { bootstrapSecret: entry.guardianBootstrapSecret },
291
+ );
289
292
  try {
290
293
  return await fn(firstToken);
291
294
  } catch (err) {
@@ -293,10 +296,13 @@ async function callRuntimeWithAuthRetry<T>(
293
296
  throw err;
294
297
  }
295
298
  const refreshedToken = await getAccessToken(
296
- runtimeUrl,
297
- assistantId,
298
- assistantId,
299
- { forceRefresh: true },
299
+ entry.runtimeUrl,
300
+ entry.assistantId,
301
+ entry.assistantId,
302
+ {
303
+ forceRefresh: true,
304
+ bootstrapSecret: entry.guardianBootstrapSecret,
305
+ },
300
306
  );
301
307
  return await fn(refreshedToken);
302
308
  }
@@ -386,8 +392,7 @@ async function exportFromAssistant(
386
392
  let sourceRuntimeVersion: string;
387
393
  try {
388
394
  const identity = await callRuntimeWithAuthRetry(
389
- entry.runtimeUrl,
390
- entry.assistantId,
395
+ entry,
391
396
  async (token) => localRuntimeIdentity(entry, token),
392
397
  );
393
398
  sourceRuntimeVersion = identity.version;
@@ -423,8 +428,7 @@ async function exportFromAssistant(
423
428
  let accessToken: string;
424
429
  try {
425
430
  const result = await callRuntimeWithAuthRetry(
426
- entry.runtimeUrl,
427
- entry.assistantId,
431
+ entry,
428
432
  async (token) => {
429
433
  const r = await localRuntimeExportToGcs(entry, token, {
430
434
  uploadUrl,
@@ -462,7 +466,10 @@ async function exportFromAssistant(
462
466
  entry.runtimeUrl,
463
467
  entry.assistantId,
464
468
  entry.assistantId,
465
- { forceRefresh: true },
469
+ {
470
+ forceRefresh: true,
471
+ bootstrapSecret: entry.guardianBootstrapSecret,
472
+ },
466
473
  );
467
474
  },
468
475
  });
@@ -728,8 +735,7 @@ async function importToAssistant(
728
735
  let targetRuntimeVersion: string;
729
736
  try {
730
737
  const identity = await callRuntimeWithAuthRetry(
731
- entry.runtimeUrl,
732
- entry.assistantId,
738
+ entry,
733
739
  (token) => localRuntimeIdentity(entry, token),
734
740
  );
735
741
  targetRuntimeVersion = identity.version;
@@ -774,8 +780,7 @@ async function importToAssistant(
774
780
  let accessToken: string;
775
781
  try {
776
782
  const result = await callRuntimeWithAuthRetry(
777
- entry.runtimeUrl,
778
- entry.assistantId,
783
+ entry,
779
784
  async (token) => {
780
785
  const r = await localRuntimeImportFromGcs(entry, token, {
781
786
  bundleUrl,
@@ -806,7 +811,10 @@ async function importToAssistant(
806
811
  entry.runtimeUrl,
807
812
  entry.assistantId,
808
813
  entry.assistantId,
809
- { forceRefresh: true },
814
+ {
815
+ forceRefresh: true,
816
+ bootstrapSecret: entry.guardianBootstrapSecret,
817
+ },
810
818
  );
811
819
  },
812
820
  });