@vellumai/cli 0.12.3-dev.202609221316.175c45b → 0.12.3-dev.202609221714.83d90c3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.12.3-dev.202609221316.175c45b",
3
+ "version": "0.12.3-dev.202609221714.83d90c3",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,257 @@
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
+ // Lockfile isolation (mirrors backup.test.ts)
16
+ const testDir = mkdtempSync(join(tmpdir(), "cli-debug-bundle-test-"));
17
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
18
+
19
+ import * as assistantConfig from "../lib/assistant-config.js";
20
+ import * as guardianToken from "../lib/guardian-token.js";
21
+ import * as localRuntimeClient from "../lib/local-runtime-client.js";
22
+ import * as platformClient from "../lib/platform-client.js";
23
+
24
+ const resolveTargetMock = spyOn(
25
+ assistantConfig,
26
+ "resolveTargetAssistant",
27
+ ).mockImplementation(() => {
28
+ throw new Error("process.exit:1");
29
+ });
30
+ const readPlatformTokenMock = spyOn(
31
+ platformClient,
32
+ "readPlatformToken",
33
+ ).mockReturnValue("platform-token");
34
+ const exportMock = spyOn(
35
+ localRuntimeClient,
36
+ "localRuntimeExportToGcs",
37
+ ).mockResolvedValue({ jobId: "job-1" });
38
+ const identityMock = spyOn(
39
+ localRuntimeClient,
40
+ "localRuntimeIdentity",
41
+ ).mockResolvedValue({ version: "0.12.3" });
42
+ const pollMock = spyOn(
43
+ localRuntimeClient,
44
+ "localRuntimePollJobStatus",
45
+ ).mockResolvedValue({
46
+ jobId: "job-1",
47
+ type: "export",
48
+ status: "complete",
49
+ } as unknown as Awaited<
50
+ ReturnType<typeof localRuntimeClient.localRuntimePollJobStatus>
51
+ >);
52
+ const loadGuardianTokenSpy = spyOn(
53
+ guardianToken,
54
+ "loadGuardianToken",
55
+ ).mockReturnValue({
56
+ accessToken: "local-token",
57
+ accessTokenExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
58
+ } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
59
+ const leaseGuardianTokenSpy = spyOn(
60
+ guardianToken,
61
+ "leaseGuardianToken",
62
+ ).mockResolvedValue({
63
+ accessToken: "leased-token",
64
+ accessTokenExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
65
+ } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
66
+
67
+ const { debugBundle } = await import("../commands/debug-bundle.js");
68
+
69
+ const LOCAL_ENTRY = {
70
+ name: "mine",
71
+ assistantId: "local-asst-1",
72
+ runtimeUrl: "http://127.0.0.1:7821",
73
+ cloud: "local",
74
+ species: "local",
75
+ hatchedAt: new Date().toISOString(),
76
+ platformAssistantId: "11111111-2222-3333-4444-555555555555",
77
+ platformBaseUrl: "https://platform.vellum.ai",
78
+ platformOrganizationId: "org-1",
79
+ } as unknown as assistantConfig.AssistantEntry;
80
+
81
+ let originalFetch: typeof globalThis.fetch;
82
+ let originalExit: typeof process.exit;
83
+ let fetchCalls: { url: string; init?: RequestInit }[] = [];
84
+ let uploadUrlStatus = 201;
85
+
86
+ beforeEach(() => {
87
+ process.argv = ["bun", "vellum", "debug-bundle"];
88
+ originalFetch = globalThis.fetch;
89
+ originalExit = process.exit;
90
+ process.exit = mock((code?: number) => {
91
+ throw new Error(`process.exit:${code}`);
92
+ }) as unknown as typeof process.exit;
93
+ fetchCalls = [];
94
+ uploadUrlStatus = 201;
95
+ globalThis.fetch = mock(
96
+ async (input: RequestInfo | URL, init?: RequestInit) => {
97
+ fetchCalls.push({ url: String(input), init });
98
+ return new Response(
99
+ JSON.stringify({ url: "https://storage.googleapis.com/b/signed" }),
100
+ {
101
+ status: uploadUrlStatus,
102
+ headers: { "Content-Type": "application/json" },
103
+ },
104
+ );
105
+ },
106
+ ) as unknown as typeof globalThis.fetch;
107
+ resolveTargetMock.mockReset();
108
+ resolveTargetMock.mockReturnValue(LOCAL_ENTRY);
109
+ identityMock.mockReset();
110
+ identityMock.mockResolvedValue({ version: "0.12.3" });
111
+ leaseGuardianTokenSpy.mockClear();
112
+ readPlatformTokenMock.mockReset();
113
+ readPlatformTokenMock.mockReturnValue("platform-token");
114
+ exportMock.mockClear();
115
+ pollMock.mockClear();
116
+ });
117
+
118
+ afterEach(() => {
119
+ globalThis.fetch = originalFetch;
120
+ process.exit = originalExit;
121
+ });
122
+
123
+ afterAll(() => {
124
+ resolveTargetMock.mockRestore();
125
+ identityMock.mockRestore();
126
+ leaseGuardianTokenSpy.mockRestore();
127
+ readPlatformTokenMock.mockRestore();
128
+ exportMock.mockRestore();
129
+ pollMock.mockRestore();
130
+ loadGuardianTokenSpy.mockRestore();
131
+ rmSync(testDir, { recursive: true, force: true });
132
+ });
133
+
134
+ describe("vellum debug-bundle", () => {
135
+ test("mints the URL on the platform, exports with the debug profile, and waits", async () => {
136
+ await debugBundle();
137
+
138
+ expect(fetchCalls).toHaveLength(1);
139
+ expect(fetchCalls[0].url).toBe(
140
+ "https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/debug-bundle-upload-url/",
141
+ );
142
+ expect(fetchCalls[0].init?.method).toBe("POST");
143
+ expect(
144
+ (fetchCalls[0].init?.headers as Record<string, string>)[
145
+ "Vellum-Organization-Id"
146
+ ],
147
+ ).toBe("org-1");
148
+
149
+ expect(exportMock).toHaveBeenCalledTimes(1);
150
+ const [entry, token, params] = exportMock.mock.calls[0];
151
+ expect(entry).toBe(LOCAL_ENTRY);
152
+ expect(token).toBe("local-token");
153
+ expect(params).toEqual({
154
+ uploadUrl: "https://storage.googleapis.com/b/signed",
155
+ description: "debug bundle for Vellum staff",
156
+ profile: "debug",
157
+ });
158
+ expect(pollMock).toHaveBeenCalled();
159
+ // The daemon was checked for the debug profile before the platform call.
160
+ expect(identityMock).toHaveBeenCalledTimes(1);
161
+ });
162
+
163
+ test("passes an unquoted multi-word display name through to the shared resolver", async () => {
164
+ process.argv = ["bun", "vellum", "debug-bundle", "Support", "Bot"];
165
+ await debugBundle();
166
+ expect(resolveTargetMock).toHaveBeenCalledWith("Support Bot");
167
+ });
168
+
169
+ test("refuses a daemon older than the debug profile before any URL is minted", async () => {
170
+ // Such a daemon strips `profile` and would upload the owner's
171
+ // credentials to the staff bucket.
172
+ identityMock.mockResolvedValue({ version: "0.12.2" });
173
+ const errors: string[] = [];
174
+ const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
175
+ errors.push(String(msg));
176
+ });
177
+ try {
178
+ await expect(debugBundle()).rejects.toThrow("process.exit:1");
179
+ } finally {
180
+ errorSpy.mockRestore();
181
+ }
182
+ expect(errors.join("\n")).toContain("0.12.3");
183
+ expect(fetchCalls).toHaveLength(0);
184
+ expect(exportMock).not.toHaveBeenCalled();
185
+ });
186
+
187
+ test("re-leases the guardian token when the daemon answers 401 mid-poll", async () => {
188
+ pollMock.mockReset();
189
+ let polls = 0;
190
+ pollMock.mockImplementation(async (_entry, token) => {
191
+ polls += 1;
192
+ if (polls === 1) {
193
+ throw new Error("Local job status check failed: 401 Unauthorized");
194
+ }
195
+ expect(token).toBe("leased-token");
196
+ return {
197
+ jobId: "job-1",
198
+ type: "export",
199
+ status: "complete",
200
+ } as unknown as Awaited<
201
+ ReturnType<typeof localRuntimeClient.localRuntimePollJobStatus>
202
+ >;
203
+ });
204
+
205
+ await debugBundle();
206
+
207
+ expect(leaseGuardianTokenSpy).toHaveBeenCalledTimes(1);
208
+ expect(polls).toBe(2);
209
+ });
210
+
211
+ test("explains what to turn on when the platform refuses", async () => {
212
+ uploadUrlStatus = 403;
213
+ const errors: string[] = [];
214
+ const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
215
+ errors.push(String(msg));
216
+ });
217
+ try {
218
+ await expect(debugBundle()).rejects.toThrow("process.exit:1");
219
+ } finally {
220
+ errorSpy.mockRestore();
221
+ }
222
+ expect(errors.join("\n")).toContain("Allow Staff Access");
223
+ expect(exportMock).not.toHaveBeenCalled();
224
+ });
225
+
226
+ test("refuses a Vellum-hosted assistant, which needs no bundle", async () => {
227
+ resolveTargetMock.mockReturnValue({
228
+ ...LOCAL_ENTRY,
229
+ cloud: "vellum",
230
+ } as unknown as assistantConfig.AssistantEntry);
231
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
232
+ try {
233
+ await expect(debugBundle()).rejects.toThrow("process.exit:1");
234
+ } finally {
235
+ errorSpy.mockRestore();
236
+ }
237
+ expect(fetchCalls).toHaveLength(0);
238
+ });
239
+
240
+ test("asks for a login when the assistant is not registered", async () => {
241
+ resolveTargetMock.mockReturnValue({
242
+ ...LOCAL_ENTRY,
243
+ platformAssistantId: undefined,
244
+ } as unknown as assistantConfig.AssistantEntry);
245
+ const errors: string[] = [];
246
+ const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
247
+ errors.push(String(msg));
248
+ });
249
+ try {
250
+ await expect(debugBundle()).rejects.toThrow("process.exit:1");
251
+ } finally {
252
+ errorSpy.mockRestore();
253
+ }
254
+ expect(errors.join("\n")).toContain("vellum login");
255
+ expect(fetchCalls).toHaveLength(0);
256
+ });
257
+ });
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `vellum debug-bundle [name]`
3
+ *
4
+ * Send Vellum staff a debug bundle of a self-hosted assistant: the
5
+ * workspace, its logs, and the gateway's own database, with no credentials.
6
+ * Staff open it on a throwaway debug clone. The platform only hands out the
7
+ * upload URL while "Allow Staff Access" is on, and the bundle is deleted
8
+ * after seven days.
9
+ *
10
+ * Steps: the daemon is checked for the debug profile, the platform mints
11
+ * the upload URL, the daemon exports with the debug profile, and this
12
+ * command waits for the job so the result is real.
13
+ */
14
+ import {
15
+ formatAssistantReference,
16
+ resolveTargetAssistant,
17
+ } from "../lib/assistant-config.js";
18
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
19
+ import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
20
+ import { pollJobUntilDone } from "../lib/job-polling.js";
21
+ import {
22
+ MigrationInProgressError,
23
+ localRuntimeExportToGcs,
24
+ localRuntimeIdentity,
25
+ localRuntimePollJobStatus,
26
+ } from "../lib/local-runtime-client.js";
27
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
28
+ import {
29
+ authHeaders,
30
+ authHeadersForKnownOrganization,
31
+ getPlatformUrl,
32
+ readPlatformToken,
33
+ } from "../lib/platform-client.js";
34
+ import { compareVersions } from "../lib/version-compat.js";
35
+
36
+ // Matches the daemon's own upload deadline (EXPORT_TO_GCS_PUT_TIMEOUT_MS in
37
+ // assistant/src/runtime/routes/migration-routes.ts), so a slow but healthy
38
+ // export is never reported as failed while it is still uploading.
39
+ const EXPORT_TIMEOUT_MS = 60 * 60 * 1000;
40
+ // The daemon release that added `profile: "debug"`. An older daemon strips
41
+ // the field and exports a normal teleport bundle, which carries the owner's
42
+ // credentials. That must never reach the staff bucket.
43
+ export const DEBUG_PROFILE_MIN_VERSION = "0.12.3";
44
+ // Renew a cached guardian token that is about to lapse instead of starting
45
+ // a long export on it.
46
+ const TOKEN_EXPIRY_MARGIN_MS = 60 * 1000;
47
+
48
+ function printHelp(): void {
49
+ console.log(`Usage: vellum debug-bundle [name]
50
+
51
+ Send Vellum staff a debug bundle of a self-hosted assistant so they can
52
+ inspect a copy of it. The bundle holds the assistant's workspace, its
53
+ logs, and the gateway's own database. It never holds credentials.
54
+
55
+ Arguments:
56
+ name Assistant ID or unique display name. Defaults to the active
57
+ assistant (see 'vellum use'). Multi-word names need no quotes.
58
+
59
+ Requirements:
60
+ - 'Allow Staff Access' is on for the assistant (Settings > Privacy).
61
+ - You are logged in ('vellum login'), which also registers the
62
+ assistant with the platform.
63
+ - The assistant runs version ${DEBUG_PROFILE_MIN_VERSION} or newer.
64
+ - The assistant is self-hosted. Vellum-hosted assistants need no bundle;
65
+ staff clone them directly.
66
+
67
+ Side effects:
68
+ - Uploads the bundle to Vellum's debug-bundle storage, where it is
69
+ deleted after 7 days. Nothing on this machine changes.
70
+ - Records a 'debug bundle exported' entry in your account's audit history.
71
+ - Waits for the upload to finish (up to 60 minutes for a large workspace).
72
+
73
+ Examples:
74
+ vellum debug-bundle
75
+ vellum debug-bundle my-assistant
76
+ vellum debug-bundle Support Bot
77
+ `);
78
+ }
79
+
80
+ export async function debugBundle(): Promise<void> {
81
+ const args = process.argv.slice(3);
82
+ if (args.includes("--help") || args.includes("-h")) {
83
+ printHelp();
84
+ return;
85
+ }
86
+
87
+ const entry = resolveTargetAssistant(parseAssistantTargetArg(args));
88
+ const reference = formatAssistantReference(entry);
89
+ if (entry.cloud === "vellum") {
90
+ console.error(
91
+ `${reference} is hosted by Vellum, so staff can clone it directly. No bundle is needed.`,
92
+ );
93
+ process.exit(1);
94
+ }
95
+
96
+ const platformToken = readPlatformToken();
97
+ if (!platformToken) {
98
+ console.error("Not logged in. Run 'vellum login' first.");
99
+ process.exit(1);
100
+ }
101
+ const platformAssistantId = entry.platformAssistantId?.trim();
102
+ if (!platformAssistantId) {
103
+ console.error(
104
+ `${reference} is not registered with the platform yet. Run 'vellum login' and retry.`,
105
+ );
106
+ process.exit(1);
107
+ }
108
+ const platformUrl = entry.platformBaseUrl?.trim() || getPlatformUrl();
109
+ const organizationId = entry.platformOrganizationId?.trim();
110
+
111
+ // Step 1: a guardian token for the daemon, and a check that the daemon
112
+ // knows the debug profile. This comes before the platform call so a
113
+ // daemon that would export credentials is refused before any URL exists.
114
+ let accessToken = await daemonAccessToken(entry, reference, false);
115
+ const { version } = await localRuntimeIdentity(entry, accessToken);
116
+ const comparison = compareVersions(version, DEBUG_PROFILE_MIN_VERSION);
117
+ if (comparison === null || comparison < 0) {
118
+ console.error(
119
+ `${reference} runs version ${version || "unknown"}, which cannot build a debug bundle. Update it to ${DEBUG_PROFILE_MIN_VERSION} or newer ('vellum upgrade') and retry.`,
120
+ );
121
+ process.exit(1);
122
+ }
123
+
124
+ // Step 2: the platform mints the upload URL, and refuses unless the
125
+ // owner's staff access grant is active.
126
+ const headers = organizationId
127
+ ? authHeadersForKnownOrganization(platformToken, organizationId)
128
+ : await authHeaders(platformToken, platformUrl);
129
+ const urlResponse = await loopbackSafeFetch(
130
+ `${platformUrl}/v1/assistants/${encodeURIComponent(platformAssistantId)}/debug-bundle-upload-url/`,
131
+ { method: "POST", headers },
132
+ );
133
+ if (urlResponse.status === 403) {
134
+ console.error(
135
+ "Staff access is off for this assistant. Turn on 'Allow Staff Access' in Settings > Privacy, then retry.",
136
+ );
137
+ process.exit(1);
138
+ }
139
+ if (!urlResponse.ok) {
140
+ const detail = await urlResponse.text().catch(() => "");
141
+ console.error(
142
+ `Error: Could not get an upload URL (${urlResponse.status}): ${detail || urlResponse.statusText}`,
143
+ );
144
+ process.exit(1);
145
+ }
146
+ const { url: uploadUrl } = (await urlResponse.json()) as { url: string };
147
+
148
+ // Step 3: the daemon builds and uploads the bundle.
149
+ let jobId: string;
150
+ try {
151
+ ({ jobId } = await localRuntimeExportToGcs(entry, accessToken, {
152
+ uploadUrl,
153
+ description: "debug bundle for Vellum staff",
154
+ profile: "debug",
155
+ }));
156
+ } catch (err) {
157
+ if (err instanceof MigrationInProgressError) {
158
+ console.error(
159
+ `Error: Another export is already in progress (job ${err.existingJobId}). Wait for it to finish, then retry.`,
160
+ );
161
+ process.exit(1);
162
+ }
163
+ throw err;
164
+ }
165
+ console.log(`Export started (job ${jobId})...`);
166
+
167
+ // Step 4: wait for the upload so the owner sees a real result. A large
168
+ // export can outlive the guardian token, so a 401 mid-poll re-leases one.
169
+ const terminal = await pollJobUntilDone({
170
+ label: "debug bundle export",
171
+ poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
172
+ timeoutMs: EXPORT_TIMEOUT_MS,
173
+ refreshOn401: async () => {
174
+ accessToken = await daemonAccessToken(entry, reference, true);
175
+ },
176
+ });
177
+ if (terminal.status === "failed") {
178
+ console.error(`Error: Export failed: ${terminal.error}`);
179
+ process.exit(1);
180
+ }
181
+ console.log(
182
+ "Debug bundle sent to Vellum. Staff can open it for the next 7 days.",
183
+ );
184
+ }
185
+
186
+ async function daemonAccessToken(
187
+ entry: {
188
+ assistantId: string;
189
+ runtimeUrl: string;
190
+ guardianBootstrapSecret?: string;
191
+ },
192
+ reference: string,
193
+ forceRefresh: boolean,
194
+ ): Promise<string> {
195
+ if (!forceRefresh) {
196
+ const cached = loadGuardianToken(entry.assistantId);
197
+ if (
198
+ cached &&
199
+ new Date(cached.accessTokenExpiresAt).getTime() - Date.now() >
200
+ TOKEN_EXPIRY_MARGIN_MS
201
+ ) {
202
+ return cached.accessToken;
203
+ }
204
+ }
205
+ try {
206
+ return (
207
+ await leaseGuardianToken(
208
+ entry.runtimeUrl,
209
+ entry.assistantId,
210
+ entry.guardianBootstrapSecret,
211
+ )
212
+ ).accessToken;
213
+ } catch (err) {
214
+ const msg = err instanceof Error ? err.message : String(err);
215
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
216
+ console.error(`Error: Could not connect to ${reference}. Is it running?`);
217
+ process.exit(1);
218
+ }
219
+ throw err;
220
+ }
221
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ import { rollback } from "./commands/rollback";
31
31
  import { setup } from "./commands/setup";
32
32
  import { sleep } from "./commands/sleep";
33
33
  import { ssh } from "./commands/ssh";
34
+ import { debugBundle } from "./commands/debug-bundle";
34
35
  import { teleport } from "./commands/teleport";
35
36
  import { terminal } from "./commands/terminal";
36
37
  import { tunnel } from "./commands/tunnel";
@@ -45,6 +46,7 @@ import { loadGuardianToken } from "./lib/guardian-token";
45
46
  import { checkHealth } from "./lib/health-check";
46
47
 
47
48
  const commands = {
49
+ "debug-bundle": debugBundle,
48
50
  backup,
49
51
  clean,
50
52
  client,
@@ -125,6 +127,9 @@ function printHelp(): void {
125
127
  console.log(" setup Configure API keys interactively");
126
128
  console.log(" sleep Stop the assistant process");
127
129
  console.log(" ssh SSH into a remote assistant instance");
130
+ console.log(
131
+ " debug-bundle Send Vellum staff a debug bundle of a self-hosted assistant",
132
+ );
128
133
  console.log(" teleport Transfer assistant data between environments");
129
134
  console.log(" terminal Open a terminal into a managed assistant container");
130
135
  console.log(" tunnel Create a tunnel for a locally hosted assistant");
@@ -116,12 +116,23 @@ async function throwIfInProgress(
116
116
  export async function localRuntimeExportToGcs(
117
117
  entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
118
118
  token: string,
119
- params: { uploadUrl: string; description?: string },
119
+ params: {
120
+ uploadUrl: string;
121
+ description?: string;
122
+ /**
123
+ * `"debug"` builds a bundle for Vellum staff: no credentials, plus the
124
+ * gateway's database and logs. Omitted means a normal teleport bundle.
125
+ */
126
+ profile?: "migration" | "debug";
127
+ },
120
128
  ): Promise<{ jobId: string }> {
121
129
  const body: Record<string, unknown> = { upload_url: params.uploadUrl };
122
130
  if (params.description !== undefined) {
123
131
  body.description = params.description;
124
132
  }
133
+ if (params.profile !== undefined) {
134
+ body.profile = params.profile;
135
+ }
125
136
 
126
137
  const response = await loopbackSafeFetch(
127
138
  resolveRuntimeMigrationUrl(entry, "export-to-gcs"),