@vellumai/cli 0.6.6 → 0.7.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 (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
package/AGENTS.md CHANGED
@@ -41,6 +41,12 @@ Every command must have high-quality `--help` output. Follow the same standards
41
41
  AI agents parse help text to decide which command to run and how. Avoid vague
42
42
  language — say exactly what the command does and where state is stored.
43
43
 
44
+ ## Boundary: No integration-specific references
45
+
46
+ The CLI is a generic lifecycle manager. It must **never** contain references to specific skills, integrations, or features (e.g. "Meet", "Slack", "Telegram"). Environment variables, volume mounts, and device passthroughs defined here must use generic names (e.g. `VELLUM_AVATAR_DEVICE`, not `VELLUM_MEET_AVATAR_DEVICE`). The skill that uses a resource decides how to interpret it — the CLI just passes it through.
47
+
48
+ Cross-package imports into `skills/` are forbidden. The CLI is distributed as an npm package; anything outside `cli/` is not included in the tarball and will fail to resolve at runtime.
49
+
44
50
  ## Boundary: No `.vellum/` directory access
45
51
 
46
52
  The CLI must **never** read from or write to the `.vellum/` directory (e.g. `~/.vellum/protected/`, `<instanceDir>/.vellum/`). That directory structure is an **assistant daemon / gateway implementation detail**. The CLI's job is to spawn those processes and pass configuration via environment variables — not to reach into their internal storage.
@@ -62,9 +68,9 @@ The CLI creates and manages Docker volumes for containerized instances. See the
62
68
  **Meet Docker-in-Docker support** (assistant container only): The assistant container runs an inner `dockerd` that hosts the Meet-bot containers as nested children. The CLI supports this by:
63
69
 
64
70
  - Creating a dedicated `<name>-dockerd-data` volume mounted at `/var/lib/docker` so pulled images and container state persist across assistant restarts.
65
- - Running the assistant container with `--privileged` (or `CAP_SYS_ADMIN` + `CAP_NET_ADMIN`) so the inner dockerd can configure cgroups, overlay mounts, and container networking.
71
+ - Running the assistant container with `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` plus `--security-opt seccomp=unconfined` + `--security-opt apparmor=unconfined` so the inner dockerd can configure cgroups, overlay mounts, and container networking without the default seccomp profile blocking clone/unshare/pivot_root syscalls or the default AppArmor profile denying its mount operations. `--privileged` is deliberately avoided — dropping it shrinks the escape surface by withholding the rest of the host capability set and access to host device nodes.
66
72
  - No longer bind-mounting the host's `/var/run/docker.sock`; Meet-bot spawning happens entirely inside the assistant container.
67
73
 
68
74
  Both are wired in `serviceDockerRunArgs()` in `lib/docker.ts`.
69
75
 
70
- The privileged assistant container is acceptable for single-user local deployments. Managed/multi-tenant mode needs a different spawn model (e.g. a Kubernetes job runner) and is out of scope for this CLI.
76
+ This capability + security-opt set is acceptable for single-user local deployments. Managed/multi-tenant mode needs a different spawn model (e.g. a Kubernetes job runner) and is out of scope for this CLI.
package/README.md CHANGED
@@ -97,6 +97,55 @@ VELLUM_CUSTOM_HOST=user@10.0.0.1 vellum hatch --remote custom
97
97
 
98
98
  When hatching on GCP in interactive mode (without `-d`), the CLI displays an animated progress TUI that polls the instance's startup script output in real time. Press `Ctrl+C` to detach -- the instance will continue running in the background.
99
99
 
100
+ ### `terminal`
101
+
102
+ Open an interactive shell into a managed assistant container. Useful for debugging, inspecting state, or working alongside the assistant in a shared `tmux` session.
103
+
104
+ ```bash
105
+ vellum terminal [name] [options]
106
+ vellum terminal attach <session> [name] [options]
107
+ vellum terminal list [name] [options]
108
+ ```
109
+
110
+ Only available for managed assistants (those running in a Vellum Cloud container). Local assistants don't have a container to terminal into.
111
+
112
+ #### Subcommands
113
+
114
+ | Subcommand | Description |
115
+ | ------------------ | ------------------------------------------------------------------------ |
116
+ | _(none)_ | Open an interactive shell session inside the container. |
117
+ | `attach <session>` | Attach to an existing `tmux` session by name inside the container. |
118
+ | `list` | List the `tmux` sessions currently running inside the container. |
119
+
120
+ #### Options
121
+
122
+ | Option | Description |
123
+ | -------------------- | -------------------------------------------------------------------------------------------- |
124
+ | `[name]` | Positional. Name of the assistant to target. Defaults to the active assistant set via `vellum use`. |
125
+ | `--assistant <name>` | Explicit form of the assistant name. Equivalent to the positional argument. |
126
+
127
+ If no assistant is named and no active assistant is set, the CLI uses the only managed assistant in the lockfile -- or errors out if there's more than one. Use `vellum ps` to see your assistants and `vellum use <name>` to set the active one.
128
+
129
+ #### Examples
130
+
131
+ ```bash
132
+ # Open a shell in the active managed assistant
133
+ vellum terminal
134
+
135
+ # Target a specific assistant by name
136
+ vellum terminal my-assistant
137
+ vellum terminal --assistant my-assistant
138
+
139
+ # List running tmux sessions inside the container
140
+ vellum terminal list
141
+
142
+ # Attach to a named tmux session
143
+ vellum terminal attach my-session
144
+ vellum terminal attach my-session my-assistant
145
+ ```
146
+
147
+ This pairs well with the [`terminal-sessions` skill](https://github.com/vellum-ai/vellum-assistant/tree/main/skills/terminal-sessions), which lets the assistant create and manage its own `tmux` sessions. You can `vellum terminal attach` into one of those sessions to watch the assistant work in real time -- for example, pairing on a long-running Claude Code run.
148
+
100
149
  ### `retire`
101
150
 
102
151
  Delete a provisioned assistant instance. The cloud provider and connection details are automatically resolved from the saved assistant config (written during `hatch`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.6.6",
3
+ "version": "0.7.1",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -216,7 +216,7 @@ describe("migrateLegacyEntry", () => {
216
216
  test("synthesises full resources when none exist", () => {
217
217
  /**
218
218
  * Tests that a legacy local entry with no resources object gets a
219
- * complete resources object synthesised with default ports and pidFile.
219
+ * complete resources object synthesised with default ports.
220
220
  */
221
221
 
222
222
  // GIVEN a local entry with no resources
@@ -239,7 +239,6 @@ describe("migrateLegacyEntry", () => {
239
239
  expect(resources.gatewayPort).toBe(7830);
240
240
  expect(resources.qdrantPort).toBe(6333);
241
241
  expect(resources.cesPort).toBe(8090);
242
- expect(resources.pidFile).toContain("vellum.pid");
243
242
  });
244
243
 
245
244
  test("infers gateway port from runtimeUrl", () => {
@@ -312,7 +311,6 @@ describe("migrateLegacyEntry", () => {
312
311
  expect(resources.gatewayPort).toBe(7830);
313
312
  expect(resources.qdrantPort).toBe(6333);
314
313
  expect(resources.cesPort).toBe(8090);
315
- expect(resources.pidFile).toBe("/custom/path/.vellum/vellum.pid");
316
314
  });
317
315
 
318
316
  test("does not overwrite existing resources fields", () => {
@@ -331,7 +329,6 @@ describe("migrateLegacyEntry", () => {
331
329
  gatewayPort: 8001,
332
330
  qdrantPort: 8002,
333
331
  cesPort: 8003,
334
- pidFile: "/my/path/.vellum/vellum.pid",
335
332
  },
336
333
  };
337
334
 
@@ -366,7 +363,6 @@ describe("migrateLegacyEntry", () => {
366
363
  daemonPort: 7821,
367
364
  gatewayPort: 7830,
368
365
  qdrantPort: 6333,
369
- pidFile: "/new/path/.vellum/vellum.pid",
370
366
  },
371
367
  };
372
368
 
@@ -393,7 +389,6 @@ describe("migrateLegacyEntry", () => {
393
389
  daemonPort: 7821,
394
390
  gatewayPort: 7830,
395
391
  qdrantPort: 6333,
396
- pidFile: "/custom/path/.vellum/vellum.pid",
397
392
  },
398
393
  };
399
394
  // WHEN we migrate the entry
@@ -415,7 +410,6 @@ describe("migrateLegacyEntry", () => {
415
410
  gatewayPort: 8001,
416
411
  qdrantPort: 8002,
417
412
  cesPort: 9090,
418
- pidFile: "/my/path/.vellum/vellum.pid",
419
413
  },
420
414
  };
421
415
  const changed = migrateLegacyEntry(entry);
@@ -0,0 +1,475 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Lockfile isolation (mirrors teleport.test.ts)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), "cli-backup-test-"));
20
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Mocks set up before importing the module under test
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import * as fs from "node:fs";
27
+
28
+ import * as assistantConfig from "../lib/assistant-config.js";
29
+ import * as backupOps from "../lib/backup-ops.js";
30
+ import * as guardianToken from "../lib/guardian-token.js";
31
+ import * as localRuntimeClient from "../lib/local-runtime-client.js";
32
+ import { MigrationInProgressError } from "../lib/local-runtime-client.js";
33
+ import * as platformClient from "../lib/platform-client.js";
34
+
35
+ const findAssistantByNameMock = spyOn(
36
+ assistantConfig,
37
+ "findAssistantByName",
38
+ ).mockReturnValue(null);
39
+
40
+ const readPlatformTokenMock = spyOn(
41
+ platformClient,
42
+ "readPlatformToken",
43
+ ).mockReturnValue("platform-token");
44
+
45
+ const getPlatformUrlMock = spyOn(
46
+ platformClient,
47
+ "getPlatformUrl",
48
+ ).mockReturnValue("https://platform.vellum.ai");
49
+
50
+ const platformRequestSignedUrlMock = spyOn(
51
+ platformClient,
52
+ "platformRequestSignedUrl",
53
+ ).mockImplementation(async (params) => ({
54
+ url:
55
+ params.operation === "upload"
56
+ ? "https://storage.googleapis.com/bucket/signed-upload"
57
+ : "https://storage.googleapis.com/bucket/signed-download",
58
+ bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
59
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
60
+ }));
61
+
62
+ const localRuntimeExportToGcsMock = spyOn(
63
+ localRuntimeClient,
64
+ "localRuntimeExportToGcs",
65
+ ).mockResolvedValue({ jobId: "platform-export-job-1" });
66
+
67
+ const localRuntimePollJobStatusMock = spyOn(
68
+ localRuntimeClient,
69
+ "localRuntimePollJobStatus",
70
+ ).mockResolvedValue({
71
+ jobId: "platform-export-job-1",
72
+ type: "export",
73
+ status: "complete",
74
+ result: { manifest_sha256: "abc123def456" },
75
+ });
76
+
77
+ // Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
78
+ // it here, but the spies need to exist so the module under test can import
79
+ // them without surprises.
80
+ spyOn(guardianToken, "loadGuardianToken").mockReturnValue({
81
+ accessToken: "local-token",
82
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
83
+ } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
84
+ spyOn(guardianToken, "leaseGuardianToken").mockResolvedValue({
85
+ accessToken: "leased-token",
86
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
87
+ } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
88
+
89
+ const getBackupsDirMock = spyOn(backupOps, "getBackupsDir").mockReturnValue(
90
+ "/tmp/backups-default",
91
+ );
92
+
93
+ const mkdirSyncMock = spyOn(fs, "mkdirSync").mockImplementation(
94
+ (() => undefined) as never,
95
+ );
96
+ const writeFileSyncMock = spyOn(fs, "writeFileSync").mockImplementation(
97
+ () => undefined,
98
+ );
99
+
100
+ let originalFetch: typeof globalThis.fetch;
101
+ let exitMock: ReturnType<typeof mock>;
102
+
103
+ const VELLUM_ENTRY = {
104
+ assistantId: "11111111-2222-3333-4444-555555555555",
105
+ runtimeUrl: "https://platform.vellum.ai",
106
+ cloud: "vellum",
107
+ species: "vellum",
108
+ hatchedAt: new Date().toISOString(),
109
+ } satisfies assistantConfig.AssistantEntry;
110
+
111
+ function setArgv(...rest: string[]) {
112
+ process.argv = ["bun", "vellum", "backup", ...rest];
113
+ }
114
+
115
+ beforeEach(() => {
116
+ originalFetch = globalThis.fetch;
117
+ exitMock = mock((code?: number) => {
118
+ throw new Error(`process.exit:${code}`);
119
+ });
120
+ process.exit = exitMock as unknown as typeof process.exit;
121
+
122
+ findAssistantByNameMock.mockReset();
123
+ findAssistantByNameMock.mockReturnValue(null);
124
+ readPlatformTokenMock.mockReset();
125
+ readPlatformTokenMock.mockReturnValue("platform-token");
126
+ getPlatformUrlMock.mockReset();
127
+ getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
128
+ platformRequestSignedUrlMock.mockReset();
129
+ platformRequestSignedUrlMock.mockImplementation(async (params) => ({
130
+ url:
131
+ params.operation === "upload"
132
+ ? "https://storage.googleapis.com/bucket/signed-upload"
133
+ : "https://storage.googleapis.com/bucket/signed-download",
134
+ bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
135
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
136
+ }));
137
+ localRuntimeExportToGcsMock.mockReset();
138
+ localRuntimeExportToGcsMock.mockResolvedValue({
139
+ jobId: "platform-export-job-1",
140
+ });
141
+ localRuntimePollJobStatusMock.mockReset();
142
+ localRuntimePollJobStatusMock.mockResolvedValue({
143
+ jobId: "platform-export-job-1",
144
+ type: "export",
145
+ status: "complete",
146
+ result: { manifest_sha256: "abc123def456" },
147
+ });
148
+ getBackupsDirMock.mockReset();
149
+ getBackupsDirMock.mockReturnValue("/tmp/backups-default");
150
+ mkdirSyncMock.mockReset();
151
+ mkdirSyncMock.mockImplementation((() => undefined) as never);
152
+ writeFileSyncMock.mockReset();
153
+ writeFileSyncMock.mockImplementation(() => undefined);
154
+ });
155
+
156
+ afterEach(() => {
157
+ globalThis.fetch = originalFetch;
158
+ });
159
+
160
+ afterAll(() => {
161
+ // Restore module-level spies so they don't bleed into other test files
162
+ // when bun test runs the whole suite.
163
+ findAssistantByNameMock.mockRestore();
164
+ readPlatformTokenMock.mockRestore();
165
+ getPlatformUrlMock.mockRestore();
166
+ platformRequestSignedUrlMock.mockRestore();
167
+ localRuntimeExportToGcsMock.mockRestore();
168
+ localRuntimePollJobStatusMock.mockRestore();
169
+ getBackupsDirMock.mockRestore();
170
+ mkdirSyncMock.mockRestore();
171
+ writeFileSyncMock.mockRestore();
172
+ rmSync(testDir, { recursive: true, force: true });
173
+ });
174
+
175
+ import { backup } from "../commands/backup.js";
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Helper: simulated GCS download response
179
+ // ---------------------------------------------------------------------------
180
+ function mockGcsDownload(body: Uint8Array, ok = true, status = 200) {
181
+ globalThis.fetch = mock(async () => {
182
+ const responseBody: BodyInit = ok
183
+ ? new Blob([body as unknown as ArrayBuffer])
184
+ : "boom";
185
+ return new Response(responseBody, {
186
+ status,
187
+ statusText: ok ? "OK" : "Error",
188
+ });
189
+ }) as unknown as typeof globalThis.fetch;
190
+ }
191
+
192
+ describe("vellum backup <platform-managed>: GCS happy path", () => {
193
+ test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
194
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
195
+ setArgv("my-platform");
196
+
197
+ const bytes = new Uint8Array([1, 2, 3, 4]);
198
+ mockGcsDownload(bytes);
199
+
200
+ await backup();
201
+
202
+ // Upload-URL request to the platform.
203
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
204
+ expect.objectContaining({ operation: "upload" }),
205
+ "platform-token",
206
+ "https://platform.vellum.ai",
207
+ );
208
+
209
+ // Runtime export-to-gcs kicked off via the entry-aware helper. URL
210
+ // construction is exercised in `local-runtime-client.test.ts`; here we
211
+ // assert the helper got the right entry + token + params.
212
+ expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
213
+ expect.objectContaining({
214
+ cloud: "vellum",
215
+ runtimeUrl: "https://platform.vellum.ai",
216
+ assistantId: "11111111-2222-3333-4444-555555555555",
217
+ }),
218
+ "platform-token",
219
+ expect.objectContaining({
220
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
221
+ description: "CLI backup",
222
+ }),
223
+ );
224
+
225
+ // Poll uses the entry-aware helper (wildcard URL, NOT the dedicated
226
+ // platform jobs/{id}/ endpoint).
227
+ expect(localRuntimePollJobStatusMock).toHaveBeenCalledWith(
228
+ expect.objectContaining({ cloud: "vellum" }),
229
+ "platform-token",
230
+ "platform-export-job-1",
231
+ );
232
+
233
+ // Download URL keyed off the upload's bundleKey.
234
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
235
+ {
236
+ operation: "download",
237
+ bundleKey: "uploads/org-1/bundle-abc.vbundle",
238
+ },
239
+ "platform-token",
240
+ "https://platform.vellum.ai",
241
+ );
242
+
243
+ // GCS fetch went directly to the signed download URL with no auth.
244
+ const gcsFetch = globalThis.fetch as unknown as ReturnType<typeof mock>;
245
+ expect(gcsFetch).toHaveBeenCalledWith(
246
+ "https://storage.googleapis.com/bucket/signed-download",
247
+ );
248
+
249
+ // File written to disk with the bytes from GCS.
250
+ expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
251
+ const [outputPath, written] = writeFileSyncMock.mock.calls[0]!;
252
+ expect(written).toEqual(bytes);
253
+ expect(typeof outputPath).toBe("string");
254
+ expect(outputPath as string).toMatch(
255
+ /\/tmp\/backups-default\/my-platform-.*\.vbundle$/,
256
+ );
257
+ expect(mkdirSyncMock).toHaveBeenCalled();
258
+ });
259
+
260
+ test("--output override is respected", async () => {
261
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
262
+ setArgv("my-platform", "--output", "/custom/path/backup.vbundle");
263
+
264
+ mockGcsDownload(new Uint8Array([7, 7, 7]));
265
+
266
+ await backup();
267
+
268
+ expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
269
+ expect(writeFileSyncMock.mock.calls[0]![0]).toBe(
270
+ "/custom/path/backup.vbundle",
271
+ );
272
+ });
273
+
274
+ test("default output path is getBackupsDir() + name-timestamp.vbundle", async () => {
275
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
276
+ setArgv("my-platform");
277
+
278
+ mockGcsDownload(new Uint8Array([1]));
279
+
280
+ await backup();
281
+
282
+ const [outputPath] = writeFileSyncMock.mock.calls[0]!;
283
+ expect(outputPath as string).toMatch(
284
+ /^\/tmp\/backups-default\/my-platform-/,
285
+ );
286
+ expect(outputPath as string).toMatch(/\.vbundle$/);
287
+ });
288
+
289
+ test("signed-URL requests target entry.runtimeUrl, not getPlatformUrl() — regression for staging/dev assistants", async () => {
290
+ // Assistant lives on a non-default platform instance (e.g. staging).
291
+ // `getPlatformUrl()` still returns the default — picking it up for
292
+ // signed URLs would target the wrong GCS bucket.
293
+ const stagingEntry = {
294
+ ...VELLUM_ENTRY,
295
+ runtimeUrl: "https://staging-platform.vellum.ai",
296
+ };
297
+ findAssistantByNameMock.mockReturnValue(stagingEntry);
298
+ getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
299
+ setArgv("my-platform");
300
+
301
+ mockGcsDownload(new Uint8Array([9]));
302
+
303
+ await backup();
304
+
305
+ // Both upload and download URL requests are pinned to the entry's
306
+ // runtimeUrl. The signed URLs returned by the platform target the
307
+ // GCS bucket the runtime can reach, not the default platform's.
308
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
309
+ expect.objectContaining({ operation: "upload" }),
310
+ "platform-token",
311
+ "https://staging-platform.vellum.ai",
312
+ );
313
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
314
+ expect.objectContaining({ operation: "download" }),
315
+ "platform-token",
316
+ "https://staging-platform.vellum.ai",
317
+ );
318
+ // No call should have used the default platform URL.
319
+ const calls = platformRequestSignedUrlMock.mock.calls;
320
+ for (const call of calls) {
321
+ expect(call[2]).toBe("https://staging-platform.vellum.ai");
322
+ }
323
+ });
324
+
325
+ test("download-URL request uses the refreshed platform token if polling re-authed mid-export", async () => {
326
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
327
+ setArgv("my-platform");
328
+
329
+ // Simulate a poll-loop refresh: the helper fires `refreshOn401`
330
+ // before resolving terminal. We trigger that hook to mutate the
331
+ // token captured by backupPlatform's closure.
332
+ localRuntimePollJobStatusMock.mockReset();
333
+ localRuntimePollJobStatusMock.mockImplementation(async () => ({
334
+ jobId: "platform-export-job-1",
335
+ type: "export",
336
+ status: "complete",
337
+ result: {},
338
+ }));
339
+ // Make readPlatformToken return a fresh value on the second call,
340
+ // mimicking the "user re-ran `vellum login` in another terminal"
341
+ // scenario. The helper's pollJobUntilDone calls refreshOn401 only
342
+ // when its own request 401s — for the test we drive the refresh
343
+ // directly by overriding the mock to surface a fresh token at the
344
+ // download-step boundary.
345
+ readPlatformTokenMock.mockReset();
346
+ readPlatformTokenMock.mockReturnValueOnce("platform-token-old");
347
+ readPlatformTokenMock.mockReturnValue("platform-token-new");
348
+
349
+ // Hook into pollJobUntilDone via overriding poll to intercept the
350
+ // refresh call. Easier: just verify the second-arg token to the
351
+ // download signed-URL request equals the one we'll inject by
352
+ // letting backup re-read the platform token mid-flight. The current
353
+ // implementation only re-reads inside pollJobUntilDone's
354
+ // `refreshOn401`, so we simulate a refresh by overriding poll to
355
+ // throw-and-recover. Instead we directly assert the regression
356
+ // behavior: backup uses `exportPlatformToken` (the closure variable)
357
+ // for the download URL — verified by the structural assertion that
358
+ // the same variable is used for upload, kickoff, poll, AND download.
359
+
360
+ mockGcsDownload(new Uint8Array([1]));
361
+
362
+ await backup();
363
+
364
+ // All four token-bearing platform calls (upload signed-URL, runtime
365
+ // export-to-gcs kickoff, poll, download signed-URL) must use the
366
+ // same token string. If the download step fell back to the captured
367
+ // `platformToken` parameter instead of `exportPlatformToken`, a
368
+ // future poll-loop refresh would silently break this invariant.
369
+ const uploadCallToken = platformRequestSignedUrlMock.mock.calls.find(
370
+ (c) => (c[0] as { operation: string }).operation === "upload",
371
+ )![1];
372
+ const downloadCallToken = platformRequestSignedUrlMock.mock.calls.find(
373
+ (c) => (c[0] as { operation: string }).operation === "download",
374
+ )![1];
375
+ expect(downloadCallToken).toBe(uploadCallToken);
376
+ const kickoffToken = localRuntimeExportToGcsMock.mock.calls[0]![1];
377
+ expect(downloadCallToken).toBe(kickoffToken);
378
+ const pollToken = localRuntimePollJobStatusMock.mock.calls[0]![1];
379
+ expect(downloadCallToken).toBe(pollToken);
380
+ });
381
+ });
382
+
383
+ describe("vellum backup <platform-managed>: failure cases", () => {
384
+ test("not logged in (no platform token) exits with 'Run vellum login'", async () => {
385
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
386
+ readPlatformTokenMock.mockReturnValue(null);
387
+ setArgv("my-platform");
388
+
389
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
390
+ () => undefined,
391
+ );
392
+ try {
393
+ await expect(backup()).rejects.toThrow("process.exit:1");
394
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
395
+ expect.stringContaining("Not logged in"),
396
+ );
397
+ } finally {
398
+ consoleErrorSpy.mockRestore();
399
+ }
400
+ });
401
+
402
+ test("MigrationInProgressError on kickoff exits with 'Another backup or teleport export'", async () => {
403
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
404
+ localRuntimeExportToGcsMock.mockRejectedValue(
405
+ new MigrationInProgressError("export_in_progress", "existing-job-99"),
406
+ );
407
+ setArgv("my-platform");
408
+
409
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
410
+ () => undefined,
411
+ );
412
+ try {
413
+ await expect(backup()).rejects.toThrow("process.exit:1");
414
+ const calls = consoleErrorSpy.mock.calls.map((c) => c[0]);
415
+ expect(
416
+ calls.some(
417
+ (m) =>
418
+ typeof m === "string" &&
419
+ m.includes("Another backup or teleport export") &&
420
+ m.includes("existing-job-99"),
421
+ ),
422
+ ).toBe(true);
423
+ } finally {
424
+ consoleErrorSpy.mockRestore();
425
+ }
426
+ });
427
+
428
+ test("terminal=failed exits with 'Export failed: <reason>'", async () => {
429
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
430
+ localRuntimePollJobStatusMock.mockResolvedValue({
431
+ jobId: "platform-export-job-1",
432
+ type: "export",
433
+ status: "failed",
434
+ error: "vbundle build crashed",
435
+ });
436
+ setArgv("my-platform");
437
+
438
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
439
+ () => undefined,
440
+ );
441
+ try {
442
+ await expect(backup()).rejects.toThrow("process.exit:1");
443
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
444
+ expect.stringContaining("Export failed: vbundle build crashed"),
445
+ );
446
+ } finally {
447
+ consoleErrorSpy.mockRestore();
448
+ }
449
+ });
450
+
451
+ test("GCS fetch !ok exits with 'Failed to fetch bundle from GCS (<status>)'", async () => {
452
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
453
+ setArgv("my-platform");
454
+
455
+ mockGcsDownload(new Uint8Array(), false, 403);
456
+
457
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
458
+ () => undefined,
459
+ );
460
+ try {
461
+ await expect(backup()).rejects.toThrow("process.exit:1");
462
+ const calls = consoleErrorSpy.mock.calls.map((c) => c[0]);
463
+ expect(
464
+ calls.some(
465
+ (m) =>
466
+ typeof m === "string" &&
467
+ m.includes("Failed to fetch bundle from GCS") &&
468
+ m.includes("403"),
469
+ ),
470
+ ).toBe(true);
471
+ } finally {
472
+ consoleErrorSpy.mockRestore();
473
+ }
474
+ });
475
+ });