@vellumai/cli 0.12.4 → 0.12.5-dev.202609250721.20229d0

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.
@@ -67,8 +67,31 @@ describe("notificationAvatarDiscHex", () => {
67
67
  });
68
68
 
69
69
  describe("notificationAvatarSvg", () => {
70
+ test("fills the entire circular crop with a custom image", () => {
71
+ const svg = notificationAvatarSvg({
72
+ kind: "image",
73
+ innerPngBase64: PNG_BASE64,
74
+ accentHex: "#E9642F",
75
+ });
76
+
77
+ expect(attributes("image", svg)).toMatchObject({
78
+ x: "0",
79
+ y: "0",
80
+ width: "256",
81
+ height: "256",
82
+ preserveAspectRatio: "xMidYMid slice",
83
+ "clip-path": "url(#notification-avatar-disc)",
84
+ });
85
+ expect(allAttributes("circle", svg)[1]).toEqual({
86
+ cx: "128",
87
+ cy: "128",
88
+ r: "128",
89
+ });
90
+ });
91
+
70
92
  test("draws one disc and one inset avatar at the default size", () => {
71
93
  const svg = notificationAvatarSvg({
94
+ kind: "character",
72
95
  innerPngBase64: PNG_BASE64,
73
96
  accentHex: "#E9642F",
74
97
  });
@@ -100,6 +123,7 @@ describe("notificationAvatarSvg", () => {
100
123
  // upload fill the square instead of letterboxing is the pair of attributes
101
124
  // asserted here.
102
125
  const svg = notificationAvatarSvg({
126
+ kind: "character",
103
127
  innerPngBase64: PNG_BASE64,
104
128
  accentHex: "#E9642F",
105
129
  });
@@ -121,6 +145,7 @@ describe("notificationAvatarSvg", () => {
121
145
 
122
146
  test("scales the geometry to a custom size", () => {
123
147
  const svg = notificationAvatarSvg({
148
+ kind: "character",
124
149
  innerPngBase64: PNG_BASE64,
125
150
  accentHex: "#E9642F",
126
151
  size: 100,
@@ -142,6 +167,7 @@ describe("notificationAvatarSvg", () => {
142
167
 
143
168
  test("carries a non-PNG inner raster with its own media type", () => {
144
169
  const svg = notificationAvatarSvg({
170
+ kind: "character",
145
171
  innerPngBase64: PNG_BASE64,
146
172
  innerMediaType: "image/jpeg",
147
173
  accentHex: "#E9642F",
@@ -154,6 +180,7 @@ describe("notificationAvatarSvg", () => {
154
180
 
155
181
  test("paints the fallback disc when there is no accent", () => {
156
182
  const svg = notificationAvatarSvg({
183
+ kind: "character",
157
184
  innerPngBase64: PNG_BASE64,
158
185
  accentHex: null,
159
186
  });
@@ -164,8 +191,21 @@ describe("notificationAvatarSvg", () => {
164
191
  });
165
192
 
166
193
  describe("notificationAvatarGeometry", () => {
194
+ test("custom images reach the disc edge at any output size", () => {
195
+ expect(notificationAvatarGeometry("image")).toEqual({
196
+ radius: 128,
197
+ offset: 0,
198
+ inner: 256,
199
+ });
200
+ expect(notificationAvatarGeometry("image", 100)).toEqual({
201
+ radius: 50,
202
+ offset: 0,
203
+ inner: 100,
204
+ });
205
+ });
206
+
167
207
  test("derives the disc, the border and the avatar edge from the size", () => {
168
- expect(notificationAvatarGeometry(100)).toEqual({
208
+ expect(notificationAvatarGeometry("character", 100)).toEqual({
169
209
  radius: 50,
170
210
  offset: 11,
171
211
  inner: 78,
@@ -173,15 +213,19 @@ describe("notificationAvatarGeometry", () => {
173
213
  });
174
214
 
175
215
  test("measures the default size when given none", () => {
176
- expect(notificationAvatarGeometry()).toEqual(
177
- notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
216
+ expect(notificationAvatarGeometry("character")).toEqual(
217
+ notificationAvatarGeometry("character", NOTIFICATION_AVATAR_SIZE),
178
218
  );
179
219
  });
180
220
 
181
221
  test("is the geometry the SVG is drawn with", () => {
182
222
  const size = 100;
183
- const { radius, offset, inner } = notificationAvatarGeometry(size);
223
+ const { radius, offset, inner } = notificationAvatarGeometry(
224
+ "character",
225
+ size,
226
+ );
184
227
  const svg = notificationAvatarSvg({
228
+ kind: "character",
185
229
  innerPngBase64: PNG_BASE64,
186
230
  accentHex: null,
187
231
  size,
@@ -15,11 +15,12 @@
15
15
  */
16
16
 
17
17
  import { isAvatarAccentHex } from "./accent.js";
18
+ import type { AvatarKind } from "./manifest.js";
18
19
 
19
20
  /** Output edge in pixels: large enough for an iOS notification thumbnail. */
20
21
  export const NOTIFICATION_AVATAR_SIZE = 256;
21
22
 
22
- /** Free disc on each side, as a fraction of the edge; the avatar gets the rest. */
23
+ /** Free disc on each side of a character avatar, as a fraction of the edge. */
23
24
  export const NOTIFICATION_AVATAR_INSET = 0.11;
24
25
 
25
26
  /** The disc when the assistant has no accent. */
@@ -48,7 +49,7 @@ export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
48
49
  * Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
49
50
  * a disc rendered by an older spec even when the avatar itself is unchanged.
50
51
  */
51
- export const NOTIFICATION_AVATAR_SPEC_VERSION = 1;
52
+ export const NOTIFICATION_AVATAR_SPEC_VERSION = 2;
52
53
 
53
54
  /** The id the inner raster's clip path is referenced by inside the document. */
54
55
  const CLIP_ID = "notification-avatar-disc";
@@ -74,24 +75,26 @@ export function notificationAvatarDiscHex(accentHex: string | null): string {
74
75
 
75
76
  /** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
76
77
  export type NotificationAvatarMediaType =
77
- | "image/png"
78
- | "image/jpeg"
79
- | "image/gif";
78
+ "image/png" | "image/jpeg" | "image/gif";
80
79
 
81
80
  /**
82
81
  * The three measurements the drawing derives from the edge: the disc's radius,
83
82
  * which is also its centre; the free border around the avatar; and the
84
83
  * avatar's own edge. Every rasterizer reads them from here, so the daemon's
85
84
  * SVG and the desktop canvas cannot drift.
85
+ * Custom images fill the disc; character avatars retain space around their
86
+ * silhouette.
86
87
  */
87
88
  export function notificationAvatarGeometry(
89
+ kind: Exclude<AvatarKind, "none">,
88
90
  size: number = NOTIFICATION_AVATAR_SIZE,
89
91
  ): { radius: number; offset: number; inner: number } {
90
- const offset = size * NOTIFICATION_AVATAR_INSET;
92
+ const offset = kind === "character" ? size * NOTIFICATION_AVATAR_INSET : 0;
91
93
  return { radius: size / 2, offset, inner: size - 2 * offset };
92
94
  }
93
95
 
94
96
  export interface NotificationAvatarSvgOptions {
97
+ kind: Exclude<AvatarKind, "none">;
95
98
  /** The avatar raster to draw inside the disc, base64 with no data prefix. */
96
99
  innerPngBase64: string;
97
100
  /** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
@@ -107,7 +110,7 @@ function px(value: number): string {
107
110
 
108
111
  /**
109
112
  * The notification avatar as an SVG document: a filled disc with the avatar
110
- * drawn inset into it.
113
+ * filling it for custom images or inset for character avatars.
111
114
  *
112
115
  * The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
113
116
  * canvas does, so a portrait or landscape upload fills the square instead of
@@ -119,12 +122,13 @@ function px(value: number): string {
119
122
  * rasterizer is known to render it.
120
123
  */
121
124
  export function notificationAvatarSvg({
125
+ kind,
122
126
  innerPngBase64,
123
127
  innerMediaType = "image/png",
124
128
  accentHex,
125
129
  size = NOTIFICATION_AVATAR_SIZE,
126
130
  }: NotificationAvatarSvgOptions): string {
127
- const { radius, offset, inner } = notificationAvatarGeometry(size);
131
+ const { radius, offset, inner } = notificationAvatarGeometry(kind, size);
128
132
  const href = `data:${innerMediaType};base64,${innerPngBase64}`;
129
133
  const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
130
134
  return (
@@ -67,8 +67,31 @@ describe("notificationAvatarDiscHex", () => {
67
67
  });
68
68
 
69
69
  describe("notificationAvatarSvg", () => {
70
+ test("fills the entire circular crop with a custom image", () => {
71
+ const svg = notificationAvatarSvg({
72
+ kind: "image",
73
+ innerPngBase64: PNG_BASE64,
74
+ accentHex: "#E9642F",
75
+ });
76
+
77
+ expect(attributes("image", svg)).toMatchObject({
78
+ x: "0",
79
+ y: "0",
80
+ width: "256",
81
+ height: "256",
82
+ preserveAspectRatio: "xMidYMid slice",
83
+ "clip-path": "url(#notification-avatar-disc)",
84
+ });
85
+ expect(allAttributes("circle", svg)[1]).toEqual({
86
+ cx: "128",
87
+ cy: "128",
88
+ r: "128",
89
+ });
90
+ });
91
+
70
92
  test("draws one disc and one inset avatar at the default size", () => {
71
93
  const svg = notificationAvatarSvg({
94
+ kind: "character",
72
95
  innerPngBase64: PNG_BASE64,
73
96
  accentHex: "#E9642F",
74
97
  });
@@ -100,6 +123,7 @@ describe("notificationAvatarSvg", () => {
100
123
  // upload fill the square instead of letterboxing is the pair of attributes
101
124
  // asserted here.
102
125
  const svg = notificationAvatarSvg({
126
+ kind: "character",
103
127
  innerPngBase64: PNG_BASE64,
104
128
  accentHex: "#E9642F",
105
129
  });
@@ -121,6 +145,7 @@ describe("notificationAvatarSvg", () => {
121
145
 
122
146
  test("scales the geometry to a custom size", () => {
123
147
  const svg = notificationAvatarSvg({
148
+ kind: "character",
124
149
  innerPngBase64: PNG_BASE64,
125
150
  accentHex: "#E9642F",
126
151
  size: 100,
@@ -142,6 +167,7 @@ describe("notificationAvatarSvg", () => {
142
167
 
143
168
  test("carries a non-PNG inner raster with its own media type", () => {
144
169
  const svg = notificationAvatarSvg({
170
+ kind: "character",
145
171
  innerPngBase64: PNG_BASE64,
146
172
  innerMediaType: "image/jpeg",
147
173
  accentHex: "#E9642F",
@@ -154,6 +180,7 @@ describe("notificationAvatarSvg", () => {
154
180
 
155
181
  test("paints the fallback disc when there is no accent", () => {
156
182
  const svg = notificationAvatarSvg({
183
+ kind: "character",
157
184
  innerPngBase64: PNG_BASE64,
158
185
  accentHex: null,
159
186
  });
@@ -164,8 +191,21 @@ describe("notificationAvatarSvg", () => {
164
191
  });
165
192
 
166
193
  describe("notificationAvatarGeometry", () => {
194
+ test("custom images reach the disc edge at any output size", () => {
195
+ expect(notificationAvatarGeometry("image")).toEqual({
196
+ radius: 128,
197
+ offset: 0,
198
+ inner: 256,
199
+ });
200
+ expect(notificationAvatarGeometry("image", 100)).toEqual({
201
+ radius: 50,
202
+ offset: 0,
203
+ inner: 100,
204
+ });
205
+ });
206
+
167
207
  test("derives the disc, the border and the avatar edge from the size", () => {
168
- expect(notificationAvatarGeometry(100)).toEqual({
208
+ expect(notificationAvatarGeometry("character", 100)).toEqual({
169
209
  radius: 50,
170
210
  offset: 11,
171
211
  inner: 78,
@@ -173,15 +213,19 @@ describe("notificationAvatarGeometry", () => {
173
213
  });
174
214
 
175
215
  test("measures the default size when given none", () => {
176
- expect(notificationAvatarGeometry()).toEqual(
177
- notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
216
+ expect(notificationAvatarGeometry("character")).toEqual(
217
+ notificationAvatarGeometry("character", NOTIFICATION_AVATAR_SIZE),
178
218
  );
179
219
  });
180
220
 
181
221
  test("is the geometry the SVG is drawn with", () => {
182
222
  const size = 100;
183
- const { radius, offset, inner } = notificationAvatarGeometry(size);
223
+ const { radius, offset, inner } = notificationAvatarGeometry(
224
+ "character",
225
+ size,
226
+ );
184
227
  const svg = notificationAvatarSvg({
228
+ kind: "character",
185
229
  innerPngBase64: PNG_BASE64,
186
230
  accentHex: null,
187
231
  size,
@@ -15,11 +15,12 @@
15
15
  */
16
16
 
17
17
  import { isAvatarAccentHex } from "./accent.js";
18
+ import type { AvatarKind } from "./manifest.js";
18
19
 
19
20
  /** Output edge in pixels: large enough for an iOS notification thumbnail. */
20
21
  export const NOTIFICATION_AVATAR_SIZE = 256;
21
22
 
22
- /** Free disc on each side, as a fraction of the edge; the avatar gets the rest. */
23
+ /** Free disc on each side of a character avatar, as a fraction of the edge. */
23
24
  export const NOTIFICATION_AVATAR_INSET = 0.11;
24
25
 
25
26
  /** The disc when the assistant has no accent. */
@@ -48,7 +49,7 @@ export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
48
49
  * Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
49
50
  * a disc rendered by an older spec even when the avatar itself is unchanged.
50
51
  */
51
- export const NOTIFICATION_AVATAR_SPEC_VERSION = 1;
52
+ export const NOTIFICATION_AVATAR_SPEC_VERSION = 2;
52
53
 
53
54
  /** The id the inner raster's clip path is referenced by inside the document. */
54
55
  const CLIP_ID = "notification-avatar-disc";
@@ -74,24 +75,26 @@ export function notificationAvatarDiscHex(accentHex: string | null): string {
74
75
 
75
76
  /** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
76
77
  export type NotificationAvatarMediaType =
77
- | "image/png"
78
- | "image/jpeg"
79
- | "image/gif";
78
+ "image/png" | "image/jpeg" | "image/gif";
80
79
 
81
80
  /**
82
81
  * The three measurements the drawing derives from the edge: the disc's radius,
83
82
  * which is also its centre; the free border around the avatar; and the
84
83
  * avatar's own edge. Every rasterizer reads them from here, so the daemon's
85
84
  * SVG and the desktop canvas cannot drift.
85
+ * Custom images fill the disc; character avatars retain space around their
86
+ * silhouette.
86
87
  */
87
88
  export function notificationAvatarGeometry(
89
+ kind: Exclude<AvatarKind, "none">,
88
90
  size: number = NOTIFICATION_AVATAR_SIZE,
89
91
  ): { radius: number; offset: number; inner: number } {
90
- const offset = size * NOTIFICATION_AVATAR_INSET;
92
+ const offset = kind === "character" ? size * NOTIFICATION_AVATAR_INSET : 0;
91
93
  return { radius: size / 2, offset, inner: size - 2 * offset };
92
94
  }
93
95
 
94
96
  export interface NotificationAvatarSvgOptions {
97
+ kind: Exclude<AvatarKind, "none">;
95
98
  /** The avatar raster to draw inside the disc, base64 with no data prefix. */
96
99
  innerPngBase64: string;
97
100
  /** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
@@ -107,7 +110,7 @@ function px(value: number): string {
107
110
 
108
111
  /**
109
112
  * The notification avatar as an SVG document: a filled disc with the avatar
110
- * drawn inset into it.
113
+ * filling it for custom images or inset for character avatars.
111
114
  *
112
115
  * The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
113
116
  * canvas does, so a portrait or landscape upload fills the square instead of
@@ -119,12 +122,13 @@ function px(value: number): string {
119
122
  * rasterizer is known to render it.
120
123
  */
121
124
  export function notificationAvatarSvg({
125
+ kind,
122
126
  innerPngBase64,
123
127
  innerMediaType = "image/png",
124
128
  accentHex,
125
129
  size = NOTIFICATION_AVATAR_SIZE,
126
130
  }: NotificationAvatarSvgOptions): string {
127
- const { radius, offset, inner } = notificationAvatarGeometry(size);
131
+ const { radius, offset, inner } = notificationAvatarGeometry(kind, size);
128
132
  const href = `data:${innerMediaType};base64,${innerPngBase64}`;
129
133
  const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
130
134
  return (
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ GUARDIAN_DECISION_ACTION_IDS,
5
+ isDenyingGuardianAction,
6
+ isParkGuardianAction,
7
+ } from "../guardian-requests.js";
8
+
9
+ describe("guardian decision actions", () => {
10
+ test("reject, leave_unverified and block deny; every other action approves", () => {
11
+ const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
12
+ isDenyingGuardianAction,
13
+ );
14
+ expect(denying).toEqual(["reject", "leave_unverified", "block"]);
15
+ });
16
+
17
+ test("only leave_unverified parks the sender", () => {
18
+ const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
19
+ expect(parking).toEqual(["leave_unverified"]);
20
+ });
21
+
22
+ test("an absent or unknown action neither denies nor parks", () => {
23
+ expect(isDenyingGuardianAction(undefined)).toBe(false);
24
+ expect(isParkGuardianAction("approve_always")).toBe(false);
25
+ });
26
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Canonical vocabulary for a guardian request's lifecycle status, shared by
3
- * the gateway that owns the request rows, the daemon that decides and
4
- * projects them, and the web that renders the projection.
2
+ * Canonical vocabulary for guardian requests, shared by the gateway that owns
3
+ * the request rows, the daemon that decides and projects them, and the web
4
+ * that renders the projection: a request's lifecycle status, the actions a
5
+ * guardian can decide it with, and the weight a card gives each action.
5
6
  */
6
7
  import { z } from "zod";
7
8
 
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
16
17
  GUARDIAN_REQUEST_STATUS_VALUES,
17
18
  );
18
19
  export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
20
+
21
+ /**
22
+ * The actions a guardian can decide a request with.
23
+ *
24
+ * `approve_once` / `reject` are the generic decision pair used by every
25
+ * request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
26
+ * introduction-card actions, valid only for `access_request` requests.
27
+ */
28
+ export const GUARDIAN_DECISION_ACTION_IDS = [
29
+ "approve_once",
30
+ "reject",
31
+ "trust",
32
+ "verify_code",
33
+ "leave_unverified",
34
+ "block",
35
+ ] as const;
36
+ export const GuardianDecisionActionIdSchema = z.enum(
37
+ GUARDIAN_DECISION_ACTION_IDS,
38
+ );
39
+ export type GuardianDecisionActionId = z.infer<
40
+ typeof GuardianDecisionActionIdSchema
41
+ >;
42
+
43
+ /** Actions that resolve a request to `denied`; every other one approves. */
44
+ export const GUARDIAN_DENYING_ACTION_VALUES = [
45
+ "reject",
46
+ "leave_unverified",
47
+ "block",
48
+ ] as const satisfies readonly GuardianDecisionActionId[];
49
+
50
+ /**
51
+ * The denying actions that park the sender at `unverified`: a neutral hold,
52
+ * not a rejection. A parked contact is still admitted under the permissive
53
+ * admission floors (`any_contact`, `strangers`) and can be trusted or verified
54
+ * later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
55
+ * decline). All three resolve the request to `denied`, so a resolved card
56
+ * consults this to read a park neutrally rather than as a denial.
57
+ */
58
+ export const GUARDIAN_PARK_ACTION_VALUES = [
59
+ "leave_unverified",
60
+ ] as const satisfies readonly GuardianDecisionActionId[];
61
+
62
+ const DENYING_ACTIONS: ReadonlySet<string> = new Set(
63
+ GUARDIAN_DENYING_ACTION_VALUES,
64
+ );
65
+ const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
66
+
67
+ /** True when `action` resolves a request to `denied`. */
68
+ export function isDenyingGuardianAction(action: string | undefined): boolean {
69
+ return action !== undefined && DENYING_ACTIONS.has(action);
70
+ }
71
+
72
+ /** True when `action` parks the sender at `unverified`. */
73
+ export function isParkGuardianAction(action: string | undefined): boolean {
74
+ return action !== undefined && PARK_ACTIONS.has(action);
75
+ }
76
+
77
+ /**
78
+ * Surface-agnostic weight of a card action. Each renderer translates it to
79
+ * its own token (Slack primary/danger, a web button variant); absent means the
80
+ * renderer's default.
81
+ */
82
+ export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
83
+ "primary",
84
+ "secondary",
85
+ "destructive",
86
+ ] as const;
87
+ export const GuardianActionEmphasisSchema = z.enum(
88
+ GUARDIAN_ACTION_EMPHASIS_VALUES,
89
+ );
90
+ export type GuardianActionEmphasis = z.infer<
91
+ typeof GuardianActionEmphasisSchema
92
+ >;
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ GUARDIAN_DECISION_ACTION_IDS,
5
+ isDenyingGuardianAction,
6
+ isParkGuardianAction,
7
+ } from "../guardian-requests.js";
8
+
9
+ describe("guardian decision actions", () => {
10
+ test("reject, leave_unverified and block deny; every other action approves", () => {
11
+ const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
12
+ isDenyingGuardianAction,
13
+ );
14
+ expect(denying).toEqual(["reject", "leave_unverified", "block"]);
15
+ });
16
+
17
+ test("only leave_unverified parks the sender", () => {
18
+ const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
19
+ expect(parking).toEqual(["leave_unverified"]);
20
+ });
21
+
22
+ test("an absent or unknown action neither denies nor parks", () => {
23
+ expect(isDenyingGuardianAction(undefined)).toBe(false);
24
+ expect(isParkGuardianAction("approve_always")).toBe(false);
25
+ });
26
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Canonical vocabulary for a guardian request's lifecycle status, shared by
3
- * the gateway that owns the request rows, the daemon that decides and
4
- * projects them, and the web that renders the projection.
2
+ * Canonical vocabulary for guardian requests, shared by the gateway that owns
3
+ * the request rows, the daemon that decides and projects them, and the web
4
+ * that renders the projection: a request's lifecycle status, the actions a
5
+ * guardian can decide it with, and the weight a card gives each action.
5
6
  */
6
7
  import { z } from "zod";
7
8
 
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
16
17
  GUARDIAN_REQUEST_STATUS_VALUES,
17
18
  );
18
19
  export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
20
+
21
+ /**
22
+ * The actions a guardian can decide a request with.
23
+ *
24
+ * `approve_once` / `reject` are the generic decision pair used by every
25
+ * request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
26
+ * introduction-card actions, valid only for `access_request` requests.
27
+ */
28
+ export const GUARDIAN_DECISION_ACTION_IDS = [
29
+ "approve_once",
30
+ "reject",
31
+ "trust",
32
+ "verify_code",
33
+ "leave_unverified",
34
+ "block",
35
+ ] as const;
36
+ export const GuardianDecisionActionIdSchema = z.enum(
37
+ GUARDIAN_DECISION_ACTION_IDS,
38
+ );
39
+ export type GuardianDecisionActionId = z.infer<
40
+ typeof GuardianDecisionActionIdSchema
41
+ >;
42
+
43
+ /** Actions that resolve a request to `denied`; every other one approves. */
44
+ export const GUARDIAN_DENYING_ACTION_VALUES = [
45
+ "reject",
46
+ "leave_unverified",
47
+ "block",
48
+ ] as const satisfies readonly GuardianDecisionActionId[];
49
+
50
+ /**
51
+ * The denying actions that park the sender at `unverified`: a neutral hold,
52
+ * not a rejection. A parked contact is still admitted under the permissive
53
+ * admission floors (`any_contact`, `strangers`) and can be trusted or verified
54
+ * later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
55
+ * decline). All three resolve the request to `denied`, so a resolved card
56
+ * consults this to read a park neutrally rather than as a denial.
57
+ */
58
+ export const GUARDIAN_PARK_ACTION_VALUES = [
59
+ "leave_unverified",
60
+ ] as const satisfies readonly GuardianDecisionActionId[];
61
+
62
+ const DENYING_ACTIONS: ReadonlySet<string> = new Set(
63
+ GUARDIAN_DENYING_ACTION_VALUES,
64
+ );
65
+ const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
66
+
67
+ /** True when `action` resolves a request to `denied`. */
68
+ export function isDenyingGuardianAction(action: string | undefined): boolean {
69
+ return action !== undefined && DENYING_ACTIONS.has(action);
70
+ }
71
+
72
+ /** True when `action` parks the sender at `unverified`. */
73
+ export function isParkGuardianAction(action: string | undefined): boolean {
74
+ return action !== undefined && PARK_ACTIONS.has(action);
75
+ }
76
+
77
+ /**
78
+ * Surface-agnostic weight of a card action. Each renderer translates it to
79
+ * its own token (Slack primary/danger, a web button variant); absent means the
80
+ * renderer's default.
81
+ */
82
+ export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
83
+ "primary",
84
+ "secondary",
85
+ "destructive",
86
+ ] as const;
87
+ export const GuardianActionEmphasisSchema = z.enum(
88
+ GUARDIAN_ACTION_EMPHASIS_VALUES,
89
+ );
90
+ export type GuardianActionEmphasis = z.infer<
91
+ typeof GuardianActionEmphasisSchema
92
+ >;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.12.4",
3
+ "version": "0.12.5-dev.202609250721.20229d0",
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"),
@@ -47,6 +47,7 @@ export const SEARCH_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
47
47
  fastcrw: "FASTCRW_API_KEY",
48
48
  searxng: "SEARXNG_API_KEY",
49
49
  tinyfish: "TINYFISH_API_KEY",
50
+ exa: "EXA_API_KEY",
50
51
  };
51
52
 
52
53
  /**